diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 81f0a752..039acc3e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -498,7 +498,7 @@ jobs: - name: Upload build artifact (Nextcloud) id: nc if: ${{ env.PRIMARY == 'nextcloud' || steps.gh.outcome == 'failure' }} - uses: trympet/nextcloud-artifacts-action@v2 + uses: trympet/nextcloud-artifacts-action@v3 continue-on-error: false with: name: SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }} diff --git a/.gitignore b/.gitignore index 516b7675..f9ed9505 100644 --- a/.gitignore +++ b/.gitignore @@ -239,3 +239,5 @@ external/streamdeck/**/images/ # Windows installer build output installer/windows/LICENSE.txt installer-out/ + +presets/test/ diff --git a/SubathonManager.Core/CsvUtils.cs b/SubathonManager.Core/CsvUtils.cs new file mode 100644 index 00000000..6a1eda0e --- /dev/null +++ b/SubathonManager.Core/CsvUtils.cs @@ -0,0 +1,127 @@ +using System.Globalization; +using System.Text; + +namespace SubathonManager.Core; + +public static class CsvUtils { + public const string TimestampFormat = "yyyy-MM-dd HH:mm:ss"; + + public static string ExportFolder => Path.Combine(Config.DataFolder, "exports"); + + public static string Escape(object? value) { + string? text = value switch { + null => null, + string s => s, + DateTime dt => dt.ToString(TimestampFormat, CultureInfo.InvariantCulture), + DateTimeOffset dto => dto.ToString(TimestampFormat, CultureInfo.InvariantCulture), + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() + }; + + if (string.IsNullOrWhiteSpace(text)) return ""; + if (text.Contains(',') || text.Contains('"') || text.Contains('\n') || text.Contains('\r')) + return $"\"{text.Replace("\"", "\"\"")}\""; + return text; + } + + public static string FormatRow(IEnumerable cells) { + return string.Join(',', cells.Select(Escape)); + } + + public static string Write(IEnumerable> headerRows, IEnumerable rows, + Func> toCells) { + var sb = new StringBuilder(); + foreach (IEnumerable header in headerRows) sb.AppendLine(FormatRow(header)); + foreach (T row in rows) sb.AppendLine(FormatRow(toCells(row))); + return sb.ToString(); + } + + public static async Task ExportAsync(string baseName, IEnumerable> headerRows, + IEnumerable rows, Func> toCells) { + Directory.CreateDirectory(ExportFolder); + + string name = SafeFileName.Sanitize(baseName, fallback: "export"); + string stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + string path = Path.Combine(ExportFolder, $"{name}-{stamp}.csv"); + await File.WriteAllTextAsync(path, Write(headerRows, rows, toCells), Encoding.UTF8); + return path; + } + + public static async Task?> ReadFileAsync(string path) { + try { + string text = await File.ReadAllTextAsync(path, Encoding.UTF8); + return ReadRecords(text) + .Select(r => r.Fields) + .Where(f => !f.All(string.IsNullOrWhiteSpace)) + .ToList(); + } + catch { + return null; + } + } + + public static List<(int Line, string[] Fields)> ReadRecords(string csv) { + var records = new List<(int, string[])>(); + var fields = new List(); + var field = new StringBuilder(); + var inQuotes = false; + var line = 1; + var recordStart = 1; + var any = false; + + if (csv.Length > 0 && csv[0] == (char)0xFEFF) csv = csv[1..]; + + for (var i = 0; i < csv.Length; i++) { + char c = csv[i]; + if (inQuotes) { + if (c == '"' && i + 1 < csv.Length && csv[i + 1] == '"') { + field.Append('"'); + i++; + } + else if (c == '"') { + inQuotes = false; + } + else { + if (c == '\n') line++; + if (c != '\r') field.Append(c); + } + + continue; + } + + switch (c) { + case '"': + inQuotes = true; + any = true; + break; + case ',': + fields.Add(field.ToString()); + field.Clear(); + any = true; + break; + case '\r': + break; + case '\n': + fields.Add(field.ToString()); + field.Clear(); + if (any || fields.Count > 1 || fields[0].Length > 0) records.Add((recordStart, fields.ToArray())); + fields.Clear(); + any = false; + line++; + recordStart = line; + break; + default: + field.Append(c); + any = true; + break; + } + } + + if (any || field.Length > 0) { + fields.Add(field.ToString()); + records.Add((recordStart, fields.ToArray())); + } + + return records; + } +} diff --git a/SubathonManager.Core/Enums/ActionEnums.cs b/SubathonManager.Core/Enums/ActionEnums.cs new file mode 100644 index 00000000..f6370033 --- /dev/null +++ b/SubathonManager.Core/Enums/ActionEnums.cs @@ -0,0 +1,648 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace SubathonManager.Core.Enums; + +public enum ActionStepType { + [ActionStepMeta(Label = "Wait", Group = "Flow", DurationLabel = "Wait for:")] + Wait = 0, + + [ActionStepMeta(Label = "If / Else", Group = "Flow", + Operations = [ + ActionOperation.IsEqual, ActionOperation.NotEqual, ActionOperation.MoreThan, ActionOperation.LessThan, + ActionOperation.AtLeast, ActionOperation.AtMost, ActionOperation.Contains, ActionOperation.NotContains, + ActionOperation.IsEmpty, ActionOperation.NotEmpty + ], + ScopeLabel = "Check:", OperationLabel = "Comparison:", TargetLabel = "Against:", + NoTargetOperations = [ActionOperation.IsEmpty, ActionOperation.NotEmpty], AllowsVariables = true)] + Condition = 1, + + [ActionStepMeta(Label = "Set Global", Group = "Flow", + Operations = [ActionOperation.Set, ActionOperation.Adjust, ActionOperation.Toggle], + TargetLabel = "Global:", BodyLabel = "Value:", NoBodyOperations = [ActionOperation.Toggle], + AllowsVariables = true)] + SetGlobal = 2, + + [ActionStepMeta(Label = "Trigger", Group = "Triggers")] + Trigger = 3, + + [ActionStepMeta(Label = "Enable / Disable Action", Group = "Custom Actions", + Operations = [ActionOperation.Enable, ActionOperation.Disable, ActionOperation.Toggle], + TargetLabel = "Action:")] + SetActionEnabled = 4, + + [ActionStepMeta(Label = "Run Action", Group = "Custom Actions", + Operations = [ActionOperation.Run, ActionOperation.Start], TargetLabel = "Action:")] + RunAction = 5, + + [ActionStepMeta(Label = "Add Time", Group = "Subathon", DurationLabel = "Time to add:")] + AddTime = 100, + + [ActionStepMeta(Label = "Subtract Time", Group = "Subathon", DurationLabel = "Time to remove:")] + SubtractTime = 101, + + [ActionStepMeta(Label = "Multiplier", Group = "Subathon", + Operations = [ActionOperation.PointsAndTime, ActionOperation.Points, ActionOperation.Time], + ValueLabel = "Multiplier (x):", DurationLabel = "Lasts for (blank = until stopped):")] + SetMultiplier = 102, + + [ActionStepMeta(Label = "Add Rerolls", Group = "Subathon", ValueLabel = "Rerolls to add:")] + Reroll = 103, + + [ActionStepMeta(Label = "Expression", Group = "VTube Studio", + Operations = [ActionOperation.On, ActionOperation.Off, ActionOperation.Toggle], + TargetLabel = "Expression file:")] + VtsExpression = 200, + + [ActionStepMeta(Label = "Parameter", Group = "VTube Studio", + Operations = [ActionOperation.Hold, ActionOperation.Set, ActionOperation.Release, ActionOperation.Restore], + TargetLabel = "Parameter:", ValueLabel = "Set value to:", + ValueOperations = [ActionOperation.Hold, ActionOperation.Set])] + VtsParameter = 201, + + [ActionStepMeta(Label = "Hotkey", Group = "VTube Studio", Operations = [ActionOperation.Trigger], + TargetLabel = "Hotkey:")] + VtsHotkey = 202, + + [ActionStepMeta(Label = "Run Command", Group = "Mix It Up", Operations = [ActionOperation.Run], + TargetLabel = "Command:", BodyLabel = "Special identifiers (one name=value per line, read as $name in MIU):", + AllowsVariables = true)] + MixItUpCommand = 300, + + // in a scene + [ActionStepMeta(Label = "Source", Group = "OBS", + Operations = [ActionOperation.Show, ActionOperation.Hide, ActionOperation.Toggle], + TargetLabel = "Source:", ScopeLabel = "Scene:")] + ObsSourceVisibility = 400, + + // on a source + [ActionStepMeta(Label = "Filter", Group = "OBS", + Operations = [ActionOperation.Enable, ActionOperation.Disable, ActionOperation.Toggle], + TargetLabel = "Filter:", ScopeLabel = "On source:")] + ObsFilter = 401, + + [ActionStepMeta(Label = "Audio", Group = "OBS", + Operations = [ActionOperation.Mute, ActionOperation.Unmute, ActionOperation.Toggle], + TargetLabel = "Input:")] + ObsAudio = 402, + + [ActionStepMeta(Label = "Media", Group = "OBS", + Operations = [ActionOperation.Play, ActionOperation.Pause, ActionOperation.Restart, ActionOperation.Stop], + TargetLabel = "Input:")] + ObsMedia = 403, + + [ActionStepMeta(Label = "Refresh Browser", Group = "OBS", TargetLabel = "Browser source:")] + ObsBrowserRefresh = 404, + + [ActionStepMeta(Label = "Raw Request", Group = "OBS", TargetLabel = "Request type:", + BodyLabel = "Request data (JSON object, optional):", AllowsVariables = true, SavesOutput = true)] + ObsRaw = 405, + + // "name=value" per line, sent as the action's arguments, found as %name% + [ActionStepMeta(Label = "Run Action", Group = "Streamer.bot", Operations = [ActionOperation.Run], + TargetLabel = "Action:", BodyLabel = "Arguments (one name=value per line):", AllowsVariables = true)] + StreamerBotAction = 500, + + [ActionStepMeta(Label = "GET Request", Group = "Web", TargetLabel = "URL:", + DurationLabel = "Timeout (blank = 15s):", + AllowsVariables = true, IsWebRequest = true)] + HttpGet = 600, + + // body is sent as-is + [ActionStepMeta(Label = "POST Request", Group = "Web", TargetLabel = "URL:", BodyLabel = "Body:", + DurationLabel = "Timeout (blank = 15s):", AllowsVariables = true, IsWebRequest = true)] + HttpPost = 601 +} + +public enum ActionOperation { + None, + On, + Off, + Toggle, + Hold, + Set, + Release, + Restore, + Trigger, + Run, + PointsAndTime, + Points, + Time, + Show, + Hide, + Enable, + Disable, + Mute, + Unmute, + Play, + Pause, + Restart, + Stop, + + // If / Else comparisons + IsEqual, + NotEqual, + MoreThan, + LessThan, + AtLeast, + AtMost, + Contains, + NotContains, + IsEmpty, + NotEmpty, + + // Set/Adjust Global on a number + Adjust, + + // Start another action, do not wait for result + Start +} + +public enum ActionStoreKind { + Global, + Secret +} + +public enum ActionValueType { + Text, + Number, + Boolean +} + +public enum ActionInputMode { + Wait, + Multiple +} + +public enum ActionHttpAuth { + None, + Bearer, + Basic +} + +public enum ActionRunResult { + Done, + Paused, + Cancelled, + Skipped +} + +public enum ActionRepeatMode { + Restart, + Parallel, + Skip, + Queue +} + +public enum ActionVariable { + [ActionVariableMeta(Token = "seconds_remaining", Group = "Timer", ValueType = "number", + Description = "Seconds left on the timer")] + SecondsRemaining, + + [ActionVariableMeta(Token = "time_remaining", Group = "Timer", ValueType = "text", + Description = "Time left as hh:mm:ss, e.g. 26:04:59")] + TimeRemaining, + + [ActionVariableMeta(Token = "seconds_elapsed", Group = "Timer", ValueType = "number", + Description = "Seconds the timer has run")] + SecondsElapsed, + + [ActionVariableMeta(Token = "timer_paused", Group = "Timer", ValueType = "true/false", + Description = "Whether the timer is paused")] + TimerPaused, + + [ActionVariableMeta(Token = "timer_locked", Group = "Timer", ValueType = "true/false", + Description = "Whether the subathon is locked")] + TimerLocked, + + [ActionVariableMeta(Token = "timer_reversed", Group = "Timer", ValueType = "true/false", + Description = "Is the timer is in reverse mode?")] + TimerReversed, + + [ActionVariableMeta(Token = "multiplier_active", Group = "Multiplier", ValueType = "true/false", + Description = "Whether a multiplier is active")] + MultiplierActive, + + [ActionVariableMeta(Token = "multiplier_amount", Group = "Multiplier", ValueType = "number", + Description = "Current multiplier amount, 1 if inactive")] + MultiplierAmount, + + [ActionVariableMeta(Token = "multiplier_seconds_remaining", Group = "Multiplier", ValueType = "number", + Description = "Seconds until the multiplier ends, 0 when it has no end or none is running")] + MultiplierSecondsRemaining, + + [ActionVariableMeta(Token = "multiplier_points", Group = "Multiplier", ValueType = "true/false", + Description = "Whether the multiplier applies to points")] + MultiplierPoints, + + [ActionVariableMeta(Token = "multiplier_time", Group = "Multiplier", ValueType = "true/false", + Description = "Whether the multiplier applies to time")] + MultiplierTime, + + [ActionVariableMeta(Token = "points", Group = "Points & Money", ValueType = "number", + Description = "Current subathon points")] + Points, + + [ActionVariableMeta(Token = "money", Group = "Points & Money", ValueType = "number", + Description = "Money raised this subathon, to 2 decimals")] + Money, + + [ActionVariableMeta(Token = "currency", Group = "Points & Money", ValueType = "text", + Description = "The subathon's currency code, e.g. USD")] + Currency, + + // should always match subathon currency, but included just in case / to open up config reading + [ActionVariableMeta(Token = "default_currency", Group = "Points & Money", ValueType = "text", + Description = "The primary currency from settings, should match subathon currency")] + DefaultCurrency, + + [ActionVariableMeta(Token = "goal_text", Group = "Goals", ValueType = "text", + Description = "The current unfinished goal, empty once all are done")] + GoalText, + + [ActionVariableMeta(Token = "goal_points", Group = "Goals", ValueType = "number", + Description = "Points (or money) needed for the current goal")] + GoalPoints, + + [ActionVariableMeta(Token = "goal_index", Group = "Goals", ValueType = "number", + Description = "Position of the current goal in the list, starting at 1; 0 when all done")] + GoalIndex, + + [ActionVariableMeta(Token = "goal_count", Group = "Goals", ValueType = "number", + Description = "How many goals in total")] + GoalCount, + + // should match points or money, but whatever is relevant for goals list + [ActionVariableMeta(Token = "goal_progress", Group = "Goals", ValueType = "number", + Description = "Points (or whole money) counted towards all goals")] + GoalProgress, + + [ActionVariableMeta(Token = "last_goal_text", Group = "Goals", ValueType = "text", + Description = "Most recently completed goal, empty if none yet")] + LastGoalText, + + [ActionVariableMeta(Token = "last_goal_points", Group = "Goals", ValueType = "number", + Description = "Points (or money) of the most recently completed goal")] + LastGoalPoints, + + [ActionVariableMeta(Token = "goal_list_name", Group = "Goals", ValueType = "text", + Description = "Name of the active goal list")] + GoalListName, + + [ActionVariableMeta(Token = "goal_type", Group = "Goals", ValueType = "text", + Description = "Are goals tracking `Points` or `Money`?")] + GoalType, + + [ActionVariableMeta(Token = "user", Group = "Action", ValueType = "text", + Description = "Who started the action run, e.g. WheelSpin, a chat user, SYSTEM or CustomAction")] + User, + + [ActionVariableMeta(Token = "source", Group = "Action", ValueType = "text", + Description = "What source started the run, e.g. WheelSpin")] + Source, + + [ActionVariableMeta(Token = "label", Group = "Action", ValueType = "text", + Description = "Name of what started the run, e.g. the wheel item's text")] + Label, + + [ActionVariableMeta(Token = "now", Group = "Action", ValueType = "text", + Description = "Local date and time the step ran, e.g. 2026-10-02 18:30:00")] + Now +} + +[ExcludeFromCodeCoverage] +public static partial class ActionStepTypeHelper { + public const int MaxNameLength = 64; + + ////////////////// variable work + + private static readonly Dictionary ByToken = Enum.GetValues() + .ToDictionary(v => v.GetToken(), v => v, StringComparer.OrdinalIgnoreCase); + + private static ActionStepMetaAttribute? Meta(ActionStepType type) { + return EnumMetaCache.Get(type); + } + + public static string GetLabel(this ActionStepType type) { + return Meta(type)?.Label ?? $"{type}"; + } + + public static string GetGroup(this ActionStepType type) { + return Meta(type)?.Group ?? ""; + } + + public static IReadOnlyList GetOps(this ActionStepType type) { + return Meta(type)?.Operations ?? []; + } + + public static ActionOperation DefaultOp(this ActionStepType type) { + IReadOnlyList ops = type.GetOps(); + return ops.Count > 0 ? ops[0] : ActionOperation.None; + } + + public static string? GetTargetLabel(this ActionStepType type) { + return Meta(type)?.TargetLabel; + } + + public static string? GetScopeLabel(this ActionStepType type) { + return Meta(type)?.ScopeLabel; + } + + public static string? GetValueLabel(this ActionStepType type) { + return Meta(type)?.ValueLabel; + } + + public static string? GetDurationLabel(this ActionStepType type) { + return Meta(type)?.DurationLabel; + } + + public static string? GetBodyLabel(this ActionStepType type) { + return Meta(type)?.BodyLabel; + } + + public static string GetOperationLabel(this ActionStepType type) { + return Meta(type)?.OperationLabel ?? "Do:"; + } + + public static bool HasTarget(this ActionStepType type) { + return type.GetTargetLabel() != null; + } + + public static bool NeedsTarget(this ActionStepType type, ActionOperation operation) { + return type.HasTarget() && !(Meta(type)?.NoTargetOperations.Contains(operation) ?? false); + } + + public static bool IsWebRequest(this ActionStepType type) { + return Meta(type)?.IsWebRequest ?? false; + } + + public static bool SavesOutput(this ActionStepType type) { + return type.IsWebRequest() || (Meta(type)?.SavesOutput ?? false); + } + + public static bool HasScope(this ActionStepType type) { + return type.GetScopeLabel() != null; + } + + public static bool HasDuration(this ActionStepType type) { + return type.GetDurationLabel() != null; + } + + public static bool HasBody(this ActionStepType type) { + return type.GetBodyLabel() != null; + } + + public static bool NeedsBody(this ActionStepType type, ActionOperation operation) { + return type.HasBody() && !(Meta(type)?.NoBodyOperations.Contains(operation) ?? false); + } + + public static bool HasValue(this ActionStepType type, ActionOperation operation) { + ActionStepMetaAttribute? meta = Meta(type); + if (meta?.ValueLabel == null) return false; + return meta.ValueOperations.Length == 0 || meta.ValueOperations.Contains(operation); + } + + public static bool AllowsVariables(this ActionStepType type) { + return Meta(type)?.AllowsVariables ?? false; + } + + public static string GetOpLabel(this ActionOperation operation) { + return operation switch { + ActionOperation.None => "Do Nothing", + ActionOperation.PointsAndTime => "Points & Time", + ActionOperation.Play => "Play / Resume", + ActionOperation.IsEqual => "Equals", + ActionOperation.NotEqual => "Not Equals", + ActionOperation.MoreThan => "Greater Than", + ActionOperation.LessThan => "Less Than", + ActionOperation.AtLeast => "Greater or Equal to", + ActionOperation.AtMost => "Less or Equal to", + ActionOperation.Contains => "Contains", + ActionOperation.NotContains => "Does Not Contain", + ActionOperation.IsEmpty => "Is Empty", + ActionOperation.NotEmpty => "Is Not Empty", + ActionOperation.Set => "Set To", + ActionOperation.Adjust => "Add/Subtract (numbers)", + ActionOperation.Run => "Run", + ActionOperation.Start => "Start - Do not wait", + _ => $"{operation}" + }; + } + + public static string GetLabel(this ActionValueType type) { + return type switch { + ActionValueType.Number => "number", + ActionValueType.Boolean => "true/false", + _ => "text" + }; + } + + public static string DefaultValue(this ActionValueType type) { + return type switch { + ActionValueType.Number => "0", + ActionValueType.Boolean => "false", + _ => "" + }; + } + + public static string? NormalizeValue(this ActionValueType type, string? raw) { + raw ??= ""; + return type switch { + ActionValueType.Number => + double.TryParse(raw.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double number) + && double.IsFinite(number) + ? number.ToString(CultureInfo.InvariantCulture) + : null, + ActionValueType.Boolean => raw.Trim().ToLowerInvariant() switch { + "true" or "yes" or "on" or "1" => "true", + "false" or "no" or "off" or "0" => "false", + _ => null + }, + _ => raw + }; + } + + public static object ToTypedValue(this ActionValueType type, string? value) { + return type switch { + ActionValueType.Number => double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, + out double number) + ? number + : 0d, + ActionValueType.Boolean => string.Equals(value, "true", StringComparison.OrdinalIgnoreCase), + _ => value ?? "" + }; + } + + public static string GetJsonType(this ActionValueType type) { + return type switch { + ActionValueType.Number => "number", + ActionValueType.Boolean => "boolean", + _ => "text" + }; + } + + public static string? ConvertValue(string? value, ActionValueType from, ActionValueType to) { + if (value == null) return null; + if (from == ActionValueType.Boolean && to == ActionValueType.Number) return value == "true" ? "1" : "0"; + return to.NormalizeValue(value); + } + + public static bool Compare(string left, ActionOperation operation, string right) { + left = left.Trim(); + right = right.Trim(); + bool numbers = double.TryParse(left, NumberStyles.Float, CultureInfo.InvariantCulture, out double l) + & double.TryParse(right, NumberStyles.Float, CultureInfo.InvariantCulture, out double r); + int order = numbers ? l.CompareTo(r) : string.Compare(left, right, StringComparison.OrdinalIgnoreCase); + + return operation switch { + // any + ActionOperation.IsEqual => order == 0, + ActionOperation.NotEqual => order != 0, + // num + ActionOperation.MoreThan => order > 0, + ActionOperation.LessThan => order < 0, + ActionOperation.AtLeast => order >= 0, + ActionOperation.AtMost => order <= 0, + // strings + ActionOperation.Contains => left.Contains(right, StringComparison.OrdinalIgnoreCase), + ActionOperation.NotContains => !left.Contains(right, StringComparison.OrdinalIgnoreCase), + ActionOperation.IsEmpty => left.Length == 0, + ActionOperation.NotEmpty => left.Length > 0, + _ => false + }; + } + + public static bool IsAvailable(this ActionStepType type) { + return type switch { + ActionStepType.VtsExpression or ActionStepType.VtsParameter or ActionStepType.VtsHotkey + => FeatureFlags.VTubeStudioEnabled, + _ => true + }; + } + + public static string GetToken(this ActionVariable variable) { + return EnumMetaCache.Get(variable)?.Token ?? $"{variable}".ToLowerInvariant(); + } + + public static string GetGroup(this ActionVariable variable) { + return EnumMetaCache.Get(variable)?.Group ?? ""; + } + + public static string GetValueType(this ActionVariable variable) { + return EnumMetaCache.Get(variable)?.ValueType ?? "text"; + } + + public static string GetDescription(this ActionVariable variable) { + return EnumMetaCache.Get(variable)?.Description ?? ""; + } + + public static string GetPlaceholder(this ActionVariable variable) { + return $"%{variable.GetToken()}%"; + } + + public static bool TryParseToken(string token, out ActionVariable variable) { + return ByToken.TryGetValue(token, out variable); + } + + public static IEnumerable FindVariables(string? text) { + if (string.IsNullOrEmpty(text)) yield break; + foreach (Match match in TokenRegex().Matches(text)) + if (TryParseToken(match.Groups[1].Value, out ActionVariable variable)) + yield return variable; + } + + public static string ReplaceVariables(string? text, IReadOnlyDictionary values) { + if (string.IsNullOrEmpty(text)) return text ?? ""; + return TokenRegex().Replace(text, m => + TryParseToken(m.Groups[1].Value, out ActionVariable variable) && + values.TryGetValue(variable, out string? value) + ? value + : m.Value); + } + + public static IEnumerable FindTokens(string? text) { + if (string.IsNullOrEmpty(text)) yield break; + foreach (Match match in TokenRegex().Matches(text)) yield return match.Groups[1].Value; + } + + public static string ReplaceTokens(string? text, Func resolve) { + if (string.IsNullOrEmpty(text)) return text ?? ""; + return TokenRegex().Replace(text, m => resolve(m.Groups[1].Value) ?? m.Value); + } + + public static bool IsValidName(string? name) { + return !string.IsNullOrEmpty(name) && name.Length <= MaxNameLength && NameRegex().IsMatch(name); + } + + public static string TokenPrefix(this ActionStoreKind kind) { + return $"{kind}".ToLower(); + } + + public static bool TryGetStoreRef(string token, out ActionStoreKind kind, out string name) { + foreach (ActionStoreKind candidate in Enum.GetValues()) { + var prefix = $"{candidate.TokenPrefix()}."; + if (!token.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + kind = candidate; + name = token[prefix.Length..]; + return IsValidName(name); + } + + kind = default; + name = ""; + return false; + } + + public static IEnumerable<(ActionStoreKind Kind, string Name)> FindStoreRefs(string? text) { + foreach (string token in FindTokens(text)) + if (TryGetStoreRef(token, out ActionStoreKind kind, out string name)) + yield return (kind, name); + } + + public static string StorePlaceholder(ActionStoreKind kind, string name) { + return $"%{kind.TokenPrefix()}.{name}%"; + } + + public static string TokenRoot(string token) { + int cut = token.IndexOfAny(['.', '[']); + return cut < 0 ? token : token[..cut]; + } + + public static string ReadJsonPath(string json, string path) { + JsonNode? node; + try { + node = JsonNode.Parse(json); + } + catch (JsonException) { + return ""; + } + + foreach (Match part in PathPartRegex().Matches(path)) { + if (part.Groups[1].Success) + node = node is JsonObject obj ? obj[part.Groups[1].Value] : null; + else + node = node is JsonArray array && int.TryParse(part.Groups[2].Value, out int index) && + index < array.Count + ? array[index] + : null; + if (node == null) return ""; + } + + return node switch { + null => "", + JsonValue value when value.TryGetValue(out string? text) => text, + _ => node.ToJsonString() + }; + } + + [GeneratedRegex(@"%([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+|\[\d+\])*)%")] + private static partial Regex TokenRegex(); + + [GeneratedRegex("^[A-Za-z0-9_]+$")] + private static partial Regex NameRegex(); + + [GeneratedRegex(@"\.([A-Za-z0-9_]+)|\[(\d+)\]")] + private static partial Regex PathPartRegex(); +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/EnumExtensions.cs b/SubathonManager.Core/Enums/EnumExtensions.cs index 6624d78e..d0f64287 100644 --- a/SubathonManager.Core/Enums/EnumExtensions.cs +++ b/SubathonManager.Core/Enums/EnumExtensions.cs @@ -94,7 +94,7 @@ public class EventTypeMetaAttribute : EnumMetaAttribute { public bool IsOther { get; init; } public bool HasValueConfig { get; init; } = true; - public bool HasCommissionData{ get; init; } + public bool HasCommissionData { get; init; } public SubathonEventSource Source { get; set; } = SubathonEventSource.Unknown; } @@ -116,7 +116,49 @@ public class CommandMetaAttribute : EnumMetaAttribute { public class WheelSpinActionMetaAttribute : EnumMetaAttribute { public bool IsCommand { get; init; } = false; public bool HasAction { get; init; } = true; - public bool IsDoneImmediately { get; init; } = false; + public bool AutoRun { get; init; } = false; + + public string QuickLabel { get; init; } = "M"; +} + +[ExcludeFromCodeCoverage] +public class ActionStepMetaAttribute : EnumMetaAttribute { + public string Group { get; init; } = ""; + public ActionOperation[] Operations { get; init; } = []; + + // editor labels + public string? TargetLabel { get; init; } + public string? ScopeLabel { get; init; } + public string? ValueLabel { get; init; } + public string? DurationLabel { get; init; } + public string? BodyLabel { get; init; } + + public string? OperationLabel { get; init; } + + // operations which have parameter vals + public ActionOperation[] ValueOperations { get; init; } = []; + + // operations which do not use the target, e.g. "is empty" + public ActionOperation[] NoTargetOperations { get; init; } = []; + + // operations which do not use the body, e.g. toggling a global + public ActionOperation[] NoBodyOperations { get; init; } = []; + + // headers, auth, timeout and an output variable for the response, precanned + public bool IsWebRequest { get; init; } + + // has output variable but not rest of web stuff + public bool SavesOutput { get; init; } + + // %variables% in config will be accepted + public bool AllowsVariables { get; init; } +} + +[ExcludeFromCodeCoverage] +public class ActionVariableMetaAttribute : EnumMetaAttribute { + public string Token { get; init; } = ""; + public string Group { get; init; } = ""; + public string ValueType { get; init; } = "text"; } public static class EnumMetaCache { diff --git a/SubathonManager.Core/Enums/SubathonCommandType.cs b/SubathonManager.Core/Enums/SubathonCommandType.cs index 3748e3cf..f784c8b2 100644 --- a/SubathonManager.Core/Enums/SubathonCommandType.cs +++ b/SubathonManager.Core/Enums/SubathonCommandType.cs @@ -60,7 +60,11 @@ public enum SubathonCommandType { SubtractSpins, [CommandMeta(Description = "Spin the Wheel", IsControlType = true)] - SpinWheel + SpinWheel, + + // param is full action + [CommandMeta(Description = "Run Custom Action", RequiresParameter = true, IsControlType = true)] + RunAction } [ExcludeFromCodeCoverage] diff --git a/SubathonManager.Core/Enums/SubathonEventSource.cs b/SubathonManager.Core/Enums/SubathonEventSource.cs index 68bfd2eb..b0749486 100644 --- a/SubathonManager.Core/Enums/SubathonEventSource.cs +++ b/SubathonManager.Core/Enums/SubathonEventSource.cs @@ -112,7 +112,11 @@ public enum SubathonEventSource { [EventSourceMeta(Description = "Tiltify", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 67, Order = 56)] - Tiltify + Tiltify, + + [EventSourceMeta(Description = "Patreon", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 68, + Order = 57)] + Patreon } [ExcludeFromCodeCoverage] diff --git a/SubathonManager.Core/Enums/SubathonEventType.cs b/SubathonManager.Core/Enums/SubathonEventType.cs index 0e3a3e89..0c91c758 100644 --- a/SubathonManager.Core/Enums/SubathonEventType.cs +++ b/SubathonManager.Core/Enums/SubathonEventType.cs @@ -216,7 +216,12 @@ public enum SubathonEventType { JuniperMerchSale, [EventTypeMeta(Label = "Donation", Source = SubathonEventSource.Tiltify, IsCurrencyDonation = true, Order = 1)] - TiltifyDonation + TiltifyDonation, + + // meta = highest tier title, synced from the campaign, or Utils.PerUnitMeta when valued by the amount pledged + // buhhhh, this is a subscription but can in a mode be used as donation stuff. Tricky + [EventTypeMeta(Label = "Membership", Source = SubathonEventSource.Patreon, IsMembership = true, Order = 1)] + PatreonPledge // any new must be added after the last } diff --git a/SubathonManager.Core/Enums/SubathonPromptType.cs b/SubathonManager.Core/Enums/SubathonPromptType.cs index df51299d..82341392 100644 --- a/SubathonManager.Core/Enums/SubathonPromptType.cs +++ b/SubathonManager.Core/Enums/SubathonPromptType.cs @@ -34,6 +34,13 @@ public enum SubathonPromptRunStatus { Cancelled } +public enum SubathonPromptActionStatus { + None, + Running, + Done, + Failed +} + [ExcludeFromCodeCoverage] public static class SubathonPromptTypeExtensions { private static readonly ReadOnlyDictionary _validSubTypes = diff --git a/SubathonManager.Core/Enums/MixItUpTrigger.cs b/SubathonManager.Core/Enums/SubathonTrigger.cs similarity index 54% rename from SubathonManager.Core/Enums/MixItUpTrigger.cs rename to SubathonManager.Core/Enums/SubathonTrigger.cs index 6396bfc7..5307d119 100644 --- a/SubathonManager.Core/Enums/MixItUpTrigger.cs +++ b/SubathonManager.Core/Enums/SubathonTrigger.cs @@ -1,14 +1,14 @@ namespace SubathonManager.Core.Enums; -public enum MixItUpTrigger { +public enum SubathonTrigger { [EnumMeta(Label = "Subathon Event", Order = 0, - Description = "Any subathon event (subs, donations, orders, etc.), and commands if enabled")] + Description = "Any subathon event (subs, donations, orders, commands, etc.)")] SubathonEvent, - [EnumMeta(Label = "Timer Paused", Order = 1, Description = "The subathon timer was paused")] + [EnumMeta(Label = "Timer Paused", Order = 1, Description = "The timer was paused")] TimerPaused, - [EnumMeta(Label = "Timer Resumed", Order = 2, Description = "The subathon timer was resumed")] + [EnumMeta(Label = "Timer Resumed", Order = 2, Description = "The timer was resumed")] TimerResumed, [EnumMeta(Label = "Timer Locked", Order = 3, Description = "The subathon was locked")] @@ -17,24 +17,28 @@ public enum MixItUpTrigger { [EnumMeta(Label = "Timer Unlocked", Order = 4, Description = "The subathon was unlocked")] TimerUnlocked, - [EnumMeta(Label = "Multiplier Started", Order = 5, Description = "A multiplier started")] + [EnumMeta(Label = "Multiplier Started", Order = 6, Description = "A multiplier started")] MultiplierStarted, - [EnumMeta(Label = "Multiplier Ended", Order = 6, Description = "A multiplier ended or was stopped")] + [EnumMeta(Label = "Multiplier Ended", Order = 7, Description = "A multiplier ended or was stopped")] MultiplierEnded, - [EnumMeta(Label = "Goal Completed", Order = 7, Description = "A goal was reached")] + [EnumMeta(Label = "Goal Completed", Order = 8, Description = "A goal was completed")] GoalCompleted, - [EnumMeta(Label = "Wheel Spin Start", Order = 8, Description = "A wheel spin started")] + [EnumMeta(Label = "Wheel Spin Start", Order = 9, Description = "A wheel spin started")] WheelSpinStart, - [EnumMeta(Label = "Wheel Spin End", Order = 9, Description = "A wheel spin landed on a result")] + [EnumMeta(Label = "Wheel Spin End", Order = 10, Description = "A wheel spin result came in")] WheelSpinEnd, - [EnumMeta(Label = "Prompt Started", Order = 10, Description = "A prompt run started")] + [EnumMeta(Label = "Prompt Started", Order = 11, Description = "A prompt run started")] PromptStarted, - [EnumMeta(Label = "Prompt Ended", Order = 11, Description = "A prompt run completed, expired or was cancelled")] - PromptEnded + [EnumMeta(Label = "Prompt Ended", Order = 12, Description = "A prompt run completed, expired or was cancelled")] + PromptEnded, + + [EnumMeta(Label = "Timer Ended", Order = 5, + Description = "The timer reached 0")] + TimerEnded } diff --git a/SubathonManager.Core/Enums/WheelSpinEnums.cs b/SubathonManager.Core/Enums/WheelSpinEnums.cs index 2ceb7f56..70b3c50b 100644 --- a/SubathonManager.Core/Enums/WheelSpinEnums.cs +++ b/SubathonManager.Core/Enums/WheelSpinEnums.cs @@ -1,29 +1,40 @@ using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Objects; namespace SubathonManager.Core.Enums; public enum WheelSpinActionType { - [WheelSpinActionMeta(HasAction = false, Label = "Manual / Other")] + [WheelSpinActionMeta(HasAction = false, Label = "Manual / Other", QuickLabel = "M")] Manual = 0, - [WheelSpinActionMeta(IsCommand = true, IsDoneImmediately = true, Label = "Add Time")] + [WheelSpinActionMeta(IsCommand = true, AutoRun = true, Label = "Add Time", QuickLabel = "+Time")] AddTime = SubathonCommandType.AddTime, - [WheelSpinActionMeta(IsCommand = true, IsDoneImmediately = true, Label = "Subtract Time")] + [WheelSpinActionMeta(IsCommand = true, AutoRun = true, Label = "Subtract Time", QuickLabel = "-Time")] SubtractTime = SubathonCommandType.SubtractTime, - [WheelSpinActionMeta(IsCommand = true, Label = "Multiplier")] + [WheelSpinActionMeta(IsCommand = true, Label = "Multiplier", QuickLabel = "Mult")] SetMultiplier = SubathonCommandType.SetMultiplier, - [WheelSpinActionMeta(IsDoneImmediately = true, Label = "Add Rerolls")] + [WheelSpinActionMeta(AutoRun = true, Label = "Add Rerolls", QuickLabel = "Reroll")] Reroll = 1000, - [WheelSpinActionMeta(Label = "VTube Studio")] - VTubeStudio = 1001 + [WheelSpinActionMeta(AutoRun = true, Label = "VTube Studio", QuickLabel = "VTS")] + VTubeStudio = 1001, + + [WheelSpinActionMeta(AutoRun = true, Label = "OBS", QuickLabel = "OBS")] + OBS = 1002, + + [WheelSpinActionMeta(AutoRun = true, Label = "Custom Action", QuickLabel = "Custom")] + CustomAction = 1003 } [ExcludeFromCodeCoverage] public static class WheelSpinActionTypeHelper { + + public static string GetQuickLabel(this WheelSpinActionType type) { + return EnumMetaCache.Get(type)?.QuickLabel ?? $"{type}"; + } public static string GetLabel(this WheelSpinActionType type) { return EnumMetaCache.Get(type)?.Label ?? $"{type}"; } @@ -36,8 +47,8 @@ public static bool IsCommand(this WheelSpinActionType type) { return EnumMetaCache.Get(type)?.IsCommand ?? false; } - public static bool IsDoneImmediately(this WheelSpinActionType type) { - return EnumMetaCache.Get(type)?.IsDoneImmediately ?? false; + public static bool IsAutoRun(this WheelSpinActionType type) { + return EnumMetaCache.Get(type)?.AutoRun ?? false; } public static bool IsAvailable(this WheelSpinActionType type) { @@ -47,7 +58,7 @@ public static bool IsAvailable(this WheelSpinActionType type) { } public static bool HasPlayAction(this WheelSpinActionType type) { - return type.IsAvailable() && (type.IsCommand() || type == WheelSpinActionType.VTubeStudio); + return type.IsAvailable() && type.HasAction() && type != WheelSpinActionType.Reroll; } public static SubathonCommandType ToCommandType(this WheelSpinActionType type) { @@ -55,10 +66,64 @@ public static SubathonCommandType ToCommandType(this WheelSpinActionType type) { return SubathonCommandType.Unknown; return (SubathonCommandType)(int)type; } + + public static ActionGraph? BuildActionGraph(this WheelSpinActionType type, string? parameter) { + parameter = (parameter ?? "").Trim(); + switch (type) { + case WheelSpinActionType.AddTime: + case WheelSpinActionType.SubtractTime: { + TimeSpan duration = Utils.ParseDurationString(parameter); + if (duration <= TimeSpan.Zero) return null; + return ActionGraph.Sequence(new ActionStep { + Type = type == WheelSpinActionType.AddTime ? ActionStepType.AddTime : ActionStepType.SubtractTime, + Seconds = duration.TotalSeconds + }); + } + case WheelSpinActionType.SetMultiplier: { + string[] parts = parameter.Split('|'); + if (parts.Length < 4 || !Utils.TryParseAmount(parts[0], out double amount)) return null; + TimeSpan duration = Utils.ParseDurationString(parts[1]); + bool.TryParse(parts[2], out bool points); + bool.TryParse(parts[3], out bool time); + return ActionGraph.Sequence(new ActionStep { + Type = ActionStepType.SetMultiplier, + Operation = points == time ? ActionOperation.PointsAndTime : points ? ActionOperation.Points : ActionOperation.Time, + Value = amount, + Seconds = duration > TimeSpan.Zero ? duration.TotalSeconds : null + }); + } + case WheelSpinActionType.Reroll: + if (!int.TryParse(parameter, out int count) || count < 1) return null; + return ActionGraph.Sequence(new ActionStep { Type = ActionStepType.Reroll, Value = count }); + case WheelSpinActionType.VTubeStudio: + return VTSWheelAction.TryParse(parameter, out VTSWheelAction? vts) ? vts.ToActionGraph() : null; + case WheelSpinActionType.OBS: + return ActionGraph.TryParse(parameter, out ActionGraph? graph) && graph.Nodes.Count > 0 ? graph : null; + default: + return null; + } + } + + public static string RepeatKey(this WheelSpinActionType type, string? parameter, Guid itemId) { + switch (type) { + case WheelSpinActionType.VTubeStudio when VTSWheelAction.TryParse(parameter, out VTSWheelAction? vts): + return vts.TimerKey; + case WheelSpinActionType.OBS when ActionGraph.TryParse(parameter, out ActionGraph? graph) + && graph.Nodes.Count > 0: { + ActionStep first = graph.Nodes[0].Step; + return $"obs-{first.Type}-{first.Scope}-{first.Target}".ToLowerInvariant(); + } + case WheelSpinActionType.CustomAction: + return $"custom-action-{parameter}".ToLowerInvariant(); + default: + return $"wheel-item-{itemId}"; + } + } } public enum WheelSpinHistoryStatus { Pending, Done, - Cancelled + Cancelled, + Running } \ No newline at end of file diff --git a/SubathonManager.Core/Events/ActionEvents.cs b/SubathonManager.Core/Events/ActionEvents.cs new file mode 100644 index 00000000..5044b694 --- /dev/null +++ b/SubathonManager.Core/Events/ActionEvents.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Models; + +namespace SubathonManager.Core.Events; + +[ExcludeFromCodeCoverage] +public static class ActionEvents { + public static event Action? CustomActionRunRequested; + + public static void RaiseCustomActionRunRequested(SubathonEvent ev) { + CustomActionRunRequested?.Invoke(ev); + } + + public static event Action>? GlobalsUpdated; + + public static void RaiseGlobalsUpdated(IReadOnlyCollection names) { + if (names.Count > 0) GlobalsUpdated?.Invoke(names); + } +} diff --git a/SubathonManager.Core/Events/IntegrationEvents.cs b/SubathonManager.Core/Events/IntegrationEvents.cs index a48323d1..3154b642 100644 --- a/SubathonManager.Core/Events/IntegrationEvents.cs +++ b/SubathonManager.Core/Events/IntegrationEvents.cs @@ -7,7 +7,7 @@ namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] public static class IntegrationEvents { public static event Action? ConnectionUpdated; // status, src, acc name, service - public static event Action>? FourthWallMembershipsSynced; + public static event Action>? MembershipTiersSynced; public static event Action? DevTunnelLegacyNotification; public static event Action? ExternalSourceSeen; @@ -20,8 +20,8 @@ public static void RaiseExternalSourceSeen(SubathonEventSource source) { ExternalSourceSeen?.Invoke(source); } - public static void RaiseFourthWallMembershipsSynced(Dictionary synced) { - FourthWallMembershipsSynced?.Invoke(synced); + public static void RaiseMembershipTiersSynced(SubathonEventSource source, IReadOnlyCollection tierNames) { + MembershipTiersSynced?.Invoke(source, tierNames); } public static void RaiseDevTunnelLegacyNotification() { diff --git a/SubathonManager.Core/Events/SubathonEvents.cs b/SubathonManager.Core/Events/SubathonEvents.cs index 0f37470b..37e31012 100644 --- a/SubathonManager.Core/Events/SubathonEvents.cs +++ b/SubathonManager.Core/Events/SubathonEvents.cs @@ -32,6 +32,11 @@ public static event Action? public static event Action? PromptRunProgressUpdated; public static event Action? PromptRunCancelRequested; public static event Action? PromptRunNowRequested; + public static event Action? PromptRunActionStatusChanged; + + public static void RaisePromptRunActionStatusChanged(SubathonPromptRun run) { + PromptRunActionStatusChanged?.Invoke(run); + } public static void RaiseSubathonTotalsUpdated(SubathonTotals totals) { SubathonTotalsUpdated?.Invoke(totals); diff --git a/SubathonManager.Core/FeatureFlags.cs b/SubathonManager.Core/FeatureFlags.cs index 6cd2427c..55d191fb 100644 --- a/SubathonManager.Core/FeatureFlags.cs +++ b/SubathonManager.Core/FeatureFlags.cs @@ -8,4 +8,5 @@ public static class FeatureFlags { public static readonly bool VTubeStudioEnabled = true; public static readonly bool VTubeStudioMarkAsExperimental = true; public static readonly bool GoAffProBackfillPollingEnabled = true; + public static readonly bool MakeShipMarkAsExperimental = true; } \ No newline at end of file diff --git a/SubathonManager.Core/Interfaces/IActionStepRunner.cs b/SubathonManager.Core/Interfaces/IActionStepRunner.cs new file mode 100644 index 00000000..075766e6 --- /dev/null +++ b/SubathonManager.Core/Interfaces/IActionStepRunner.cs @@ -0,0 +1,10 @@ +using SubathonManager.Core.Enums; +using SubathonManager.Core.Objects; + +namespace SubathonManager.Core.Interfaces; + +public interface IActionStepRunner { + IReadOnlyCollection StepTypes { get; } + + Task RunStepAsync(ActionStep step, ActionContext ctx, ActionRunProgress progress, CancellationToken ct); +} diff --git a/SubathonManager.Core/Interfaces/IMissedEventSource.cs b/SubathonManager.Core/Interfaces/IMissedEventSource.cs new file mode 100644 index 00000000..6f8439d9 --- /dev/null +++ b/SubathonManager.Core/Interfaces/IMissedEventSource.cs @@ -0,0 +1,7 @@ +using SubathonManager.Core.Models; + +namespace SubathonManager.Core.Interfaces; + +public interface IMissedEventSource { + Task> FetchMissedEventsAsync(DateTime from, DateTime to, CancellationToken ct = default); +} diff --git a/SubathonManager.Core/Models/ActionGlobal.cs b/SubathonManager.Core/Models/ActionGlobal.cs new file mode 100644 index 00000000..4efec1a6 --- /dev/null +++ b/SubathonManager.Core/Models/ActionGlobal.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Enums; + +namespace SubathonManager.Core.Models; + +[ExcludeFromCodeCoverage] +public class ActionGlobal { + public ActionStoreKind Kind { get; set; } + [MaxLength(64)] public string Name { get; set; } = ""; + + public ActionValueType ValueType { get; set; } = ActionValueType.Text; + + public string? Value { get; set; } + + // secrets only + [MaxLength(128)] public string? StorageKey { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.Now; + public DateTime UpdatedAt { get; set; } = DateTime.Now; +} +public record GlobalTypeConflict(string Name, ActionValueType Existing, ActionValueType Wanted); diff --git a/SubathonManager.Core/Models/MakeShipTracking.cs b/SubathonManager.Core/Models/MakeShipTracking.cs index 583264bb..3cd6d70e 100644 --- a/SubathonManager.Core/Models/MakeShipTracking.cs +++ b/SubathonManager.Core/Models/MakeShipTracking.cs @@ -16,4 +16,5 @@ public class MakeShipTracking { public int Orders { get; set; } [NotMapped] public bool PollFailing { get; set; } + [NotMapped] public bool Blocked { get; set; } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonGoal.cs b/SubathonManager.Core/Models/SubathonGoal.cs index e81ee846..9dcd75ed 100644 --- a/SubathonManager.Core/Models/SubathonGoal.cs +++ b/SubathonManager.Core/Models/SubathonGoal.cs @@ -10,6 +10,7 @@ public class SubathonGoal { public string Text { get; set; } = "New Goal"; public long Points { get; set; } = 1; + public bool IsCompleted { get; set; } [ForeignKey("SubathonGoalSet")] public Guid? GoalSetId { get; set; } diff --git a/SubathonManager.Core/Models/SubathonPrompt.cs b/SubathonManager.Core/Models/SubathonPrompt.cs index 374c8502..9bcc53fc 100644 --- a/SubathonManager.Core/Models/SubathonPrompt.cs +++ b/SubathonManager.Core/Models/SubathonPrompt.cs @@ -29,6 +29,9 @@ public class SubathonPrompt { public int Index { get; set; } = 0; + // optional action + public Guid? CustomActionId { get; set; } + public bool HasStock() { return IsInfinite || Quantity > 0; } @@ -59,6 +62,10 @@ public class SubathonPromptRun { public long SnapshotTargetValue { get; set; } public long BaselineCount { get; set; } = 0; + public Guid? ActionId { get; set; } + public SubathonPromptActionStatus ActionStatus { get; set; } = SubathonPromptActionStatus.None; + public string? ActionProgress { get; set; } + public bool IsActive => Status == SubathonPromptRunStatus.Active; public bool IsExpired => DateTime.Now >= ExpiresAt && Status == SubathonPromptRunStatus.Active; diff --git a/SubathonManager.Core/Models/Wheels.cs b/SubathonManager.Core/Models/Wheels.cs index 2b50e3ec..e43d352c 100644 --- a/SubathonManager.Core/Models/Wheels.cs +++ b/SubathonManager.Core/Models/Wheels.cs @@ -73,4 +73,7 @@ public class WheelSpinHistory { public DateTime UpdatedAt { get; set; } = DateTime.Now; public WheelSpinHistoryStatus Status { get; set; } = WheelSpinHistoryStatus.Pending; + + // ActionRunProgress json - which steps ran, so resume picks it up + public string? ActionProgress { get; set; } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/Widget.cs b/SubathonManager.Core/Models/Widget.cs index 441eb340..aa91c0a9 100644 --- a/SubathonManager.Core/Models/Widget.cs +++ b/SubathonManager.Core/Models/Widget.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; @@ -117,8 +118,8 @@ public string GetInjectLine() { else if (Type == WidgetVariableType.Boolean && bool.TryParse(Value, out bool b)) { sb.Append($"{b.ToString().ToLower()}"); } - else if (Type == WidgetVariableType.Float && float.TryParse(Value, out float f)) { - sb.Append($"{f}"); + else if (Type == WidgetVariableType.Float && Utils.TryParseAmount(Value, out double f)) { + sb.Append(((float)f).ToString(CultureInfo.InvariantCulture)); } else if (Type == WidgetVariableType.Int && int.TryParse(Value, out int intValue)) { sb.Append($"{intValue}"); @@ -235,6 +236,27 @@ public partial class Widget { public WidgetType Type { get; set; } = WidgetType.Html; + [MaxLength(4096)] public string GlobalVars { get; set; } = string.Empty; + + public const string AllGlobals = "*"; + + [NotMapped] public bool ListensToAllGlobals => GlobalVars.Trim() == AllGlobals; + + [NotMapped] + public IReadOnlyList GlobalVarNames => ListensToAllGlobals + ? [] + : GlobalVars.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + public bool ListensToGlobal(string name) { + return ListensToAllGlobals || GlobalVarNames.Contains(name, StringComparer.OrdinalIgnoreCase); + } + + public static string JoinGlobalVars(bool all, IEnumerable names) { + return all ? AllGlobals : string.Join(",", names.Select(n => n.Trim()).Where(n => n.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase)); + } + public Widget(string name, string htmlPath) { Name = name; HtmlPath = htmlPath; @@ -253,6 +275,7 @@ public Widget Clone(Guid? routeId, string? newName, int? newZ) { widget.ScaleX = ScaleX; widget.ScaleY = ScaleY; widget.Type = Type; + widget.GlobalVars = GlobalVars; foreach (JsVariable jsVariable in JsVariables) widget.JsVariables.Add(jsVariable.Clone(widget.Id)); @@ -380,6 +403,7 @@ public JsonElement ToJson(string htmlRelPath) { visibility = Visibility, docsUrl = DocsUrl, + globalVars = GlobalVars, cssVariables = CssVariables.Select(v => v.ToJson()).ToArray(), jsVariables = JsVariables.Select(v => v.ToJson()).ToArray() diff --git a/SubathonManager.Core/Objects/ActionGraph.cs b/SubathonManager.Core/Objects/ActionGraph.cs new file mode 100644 index 00000000..b9b29898 --- /dev/null +++ b/SubathonManager.Core/Objects/ActionGraph.cs @@ -0,0 +1,649 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + +namespace SubathonManager.Core.Objects; + +public sealed class ActionStep { + public ActionStepType Type { get; set; } = ActionStepType.Wait; + public ActionOperation Operation { get; set; } = ActionOperation.None; + + public string Target { get; set; } = ""; + public string? TargetName { get; set; } + public string? Scope { get; set; } + public double? Value { get; set; } + public double? Seconds { get; set; } + public string? Body { get; set; } + public string? Headers { get; set; } + + public ActionHttpAuth? Auth { get; set; } + public string? AuthUser { get; set; } + public string? AuthToken { get; set; } + public string? OutputVariable { get; set; } + + public SubathonTrigger? Trigger { get; set; } + public List? EventTypes { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool IgnoreSimulated { get; set; } + + [JsonIgnore] public TimeSpan Duration => Seconds is > 0.0 ? TimeSpan.FromSeconds(Seconds.Value) : TimeSpan.Zero; + + [JsonIgnore] public string AllText => string.Join('\n', Target, Scope, Body, Headers, AuthUser, AuthToken); + + private string OutputSuffix => string.IsNullOrEmpty(OutputVariable) ? "" : $" -> %{OutputVariable}%"; + + public ActionStep Fill(Func fill) { + return new ActionStep { + Type = Type, + Operation = Operation, + Target = fill(Target), + TargetName = TargetName, + Scope = Scope == null ? null : fill(Scope), + Value = Value, + Seconds = Seconds, + Body = Body == null ? null : fill(Body), + Headers = Headers == null ? null : fill(Headers), + Auth = Auth, + AuthUser = AuthUser == null ? null : fill(AuthUser), + AuthToken = AuthToken == null ? null : fill(AuthToken), + OutputVariable = OutputVariable, + Trigger = Trigger, + EventTypes = EventTypes == null ? null : [..EventTypes], + IgnoreSimulated = IgnoreSimulated + }; + } + + public bool IsValid(out string error) { + error = ""; + IReadOnlyList ops = Type.GetOps(); + if (ops.Count > 0 && !ops.Contains(Operation)) { + error = $"{Type.GetLabel()} cannot use '{Operation}'"; + return false; + } + + switch (Type) { + case ActionStepType.Wait: + case ActionStepType.AddTime: + case ActionStepType.SubtractTime: + if (Duration > TimeSpan.Zero) return true; + error = $"{Type.GetLabel()} needs a duration"; + return false; + case ActionStepType.SetMultiplier: + if (Value is > 0.0) return true; + error = "Multiplier needs an amount above 0"; + return false; + case ActionStepType.Reroll: + if (Value is >= 1.0) return true; + error = "Rerolls need a count of at least 1"; + return false; + case ActionStepType.VtsParameter when Type.HasValue(Operation) && Value == null: + error = "Pick a value to set the parameter to"; + return false; + case ActionStepType.HttpGet or ActionStepType.HttpPost when !LooksLikeUrl(Target): + error = "The URL needs to start with http:// or https://"; + return false; + case ActionStepType.Trigger when Trigger == null: + error = "Pick a trigger for this"; + return false; + case ActionStepType.Trigger when Trigger == SubathonTrigger.SubathonEvent && (EventTypes?.Count ?? 0) == 0: + error = "Pick at least one event type"; + return false; + case ActionStepType.SetGlobal when !ActionStepTypeHelper.IsValidName(Target.Trim()): + error = "Pick a global by name"; + return false; + case ActionStepType.SetGlobal when Operation == ActionOperation.Adjust && string.IsNullOrWhiteSpace(Body): + error = "Give an amount to add (negative to remove)"; + return false; + } + + if (Type.SavesOutput() && !string.IsNullOrEmpty(OutputVariable) && !IsValidOutputName(OutputVariable)) { + error = + $"\"{OutputVariable}\" can't be an output name: use letters, numbers, _, and not a built-in variable's name"; + return false; + } + + if (Type == ActionStepType.ObsRaw && !string.IsNullOrWhiteSpace(Body) && !Body.Contains('%') + && !IsJsonObject(Body)) { + error = "Request data needs to be a valid JSON object, e.g. {\"sceneName\": \"Main\"}"; + return false; + } + + if (Type.IsWebRequest()) { + if (Auth == ActionHttpAuth.Bearer && string.IsNullOrWhiteSpace(AuthToken)) { + error = "Bearer auth needs a token, e.g. %secret.my_token%"; + return false; + } + + if (Auth == ActionHttpAuth.Basic && string.IsNullOrWhiteSpace(AuthUser)) { + error = "Basic auth needs a username"; + return false; + } + } + + if (Type.HasScope() && string.IsNullOrWhiteSpace(Scope)) { + error = $"Pick the {Type.GetScopeLabel()!.TrimEnd(':').ToLowerInvariant()} first"; + return false; + } + + if (Type.NeedsTarget(Operation) && string.IsNullOrWhiteSpace(Target)) { + error = + $"{Type.GetGroup()} {Type.GetLabel()} needs a {Type.GetTargetLabel()!.TrimEnd(':').ToLowerInvariant()}"; + return false; + } + + return true; + } + + public static bool IsValidOutputName(string name) { + return ActionStepTypeHelper.IsValidName(name) + && !ActionStepTypeHelper.TryParseToken(name, out _) + && !name.Equals(ActionRunProgress.TriggerVariable, StringComparison.OrdinalIgnoreCase) + && !Enum.GetValues() + .Any(k => name.Equals(k.TokenPrefix(), StringComparison.OrdinalIgnoreCase)); + } + + public bool MatchesTrigger(SubathonTrigger trigger, SubathonEvent? subathonEvent) { + if (Type != ActionStepType.Trigger || Trigger != trigger) return false; + if (trigger != SubathonTrigger.SubathonEvent) return true; + if (subathonEvent == null) return false; + if (IgnoreSimulated && subathonEvent.Source == SubathonEventSource.Simulated) return false; + + var eventType = $"{subathonEvent.EventType}"; + return EventTypes?.Contains(eventType, StringComparer.OrdinalIgnoreCase) ?? false; + } + + private static string EventTypeLabel(string eventType) { + return Enum.TryParse(eventType, out SubathonEventType type) + ? $"{((SubathonEventType?)type).GetSource()} {((SubathonEventType?)type).GetLabel()}" + : eventType; + } + + private static bool IsJsonObject(string text) { + try { + return JsonNode.Parse(text) is JsonObject; + } + catch (JsonException) { + return false; + } + } + + private static bool LooksLikeUrl(string url) { + url = url.Trim(); + return url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith('%'); + } + + public string Describe() { + string target = string.IsNullOrWhiteSpace(TargetName) ? Target : TargetName; + string value = Value?.ToString(CultureInfo.InvariantCulture) ?? "?"; + string duration = Utils.FormatShortDuration(Duration); + string applies = Operation switch { + ActionOperation.Points => "points", + ActionOperation.Time => "time", + _ => "points & time" + }; + + // messy for all but cant think of how else to maintain rn + return Type switch { + ActionStepType.Wait => $"Wait {duration}", + ActionStepType.AddTime => $"Add {duration}", + ActionStepType.SubtractTime => $"Subtract {duration}", + ActionStepType.SetMultiplier => + $"x{value} {applies} {(Duration > TimeSpan.Zero ? $"for {duration}" : "until stopped")}", + ActionStepType.Reroll => $"+{value} reroll{(Value is 1.0 ? "" : "s")}", + ActionStepType.VtsExpression => $"{target} {Operation}", + ActionStepType.VtsParameter => Operation switch { + ActionOperation.Hold => $"{target} = {value} (held)", + ActionOperation.Set => $"{target} = {value}", + ActionOperation.Restore => $"Restore {target}", + _ => $"Release {target}" + }, + ActionStepType.VtsHotkey => $"Hotkey \"{target}\"", + ActionStepType.MixItUpCommand => $"MixItUp \"{target}\"", + ActionStepType.ObsSourceVisibility => $"{Operation} \"{target}\" in \"{Scope}\"", + ActionStepType.ObsFilter => $"{Operation} filter \"{target}\" on \"{Scope}\"", + ActionStepType.ObsAudio or ActionStepType.ObsMedia => $"{Operation} \"{target}\"", + ActionStepType.SetActionEnabled => $"{Operation} action \"{target}\"", + ActionStepType.RunAction => Operation == ActionOperation.Start + ? $"Start action \"{target}\"" + : $"Run action \"{target}\"", + ActionStepType.ObsBrowserRefresh => $"Refresh \"{target}\"", + ActionStepType.ObsRaw => $"OBS {target}{OutputSuffix}", + ActionStepType.StreamerBotAction => $"Streamer.bot \"{target}\"", + ActionStepType.Condition => Type.NeedsTarget(Operation) + ? $"If {Scope} is {Operation.GetOpLabel().ToLowerInvariant()} {Target}" + : $"If {Scope} is {Operation.GetOpLabel().ToLowerInvariant()}", + ActionStepType.Trigger => Trigger switch { + null => "When ...", + SubathonTrigger.SubathonEvent => (EventTypes?.Count ?? 0) switch { + 0 => "When a subathon event (none picked)", + 1 => $"When {EventTypeLabel(EventTypes![0])}", + _ => $"When {EventTypeLabel(EventTypes![0])} +{EventTypes!.Count - 1} more" + } + (IgnoreSimulated ? ", not simulated" : ""), + _ => $"When {Trigger.Value.GetLabel().ToLowerInvariant()}" + }, + ActionStepType.SetGlobal => Operation switch { + ActionOperation.Toggle => $"Toggle %global.{Target}%", + ActionOperation.Adjust => $"%global.{Target}% += {Body?.Trim()}", + _ => $"%global.{Target}% = {Body?.Trim()}" + }, + ActionStepType.HttpGet => $"GET {ShortUrl(Target)}{OutputSuffix}", + ActionStepType.HttpPost => $"POST {ShortUrl(Target)}{OutputSuffix}", + _ => Type.GetLabel() + }; + } + + public IEnumerable<(string Name, string Value)> BodyArguments() { + return ParseArguments(Body); + } + + public IEnumerable<(string Name, string Value)> HeaderLines() { + return ParseArguments(Headers, ':'); + } + + public static IEnumerable<(string Name, string Value)> ParseArguments(string? body, char separator = '=') { + if (string.IsNullOrWhiteSpace(body)) yield break; + foreach (string raw in body.Split('\n')) { + string line = raw.Trim(); + int split = line.IndexOf(separator); + if (split <= 0) continue; + yield return (line[..split].Trim(), line[(split + 1)..].Trim()); + } + } + + private static string ShortUrl(string url) { + // save space on node cards via trimming urls + string trimmed = url.Trim(); + int scheme = trimmed.IndexOf("://", StringComparison.Ordinal); + if (scheme >= 0) trimmed = trimmed[(scheme + 3)..]; + return trimmed.Length > 48 ? $"{trimmed[..45]}..." : trimmed; + } +} + +public sealed class ActionNode { + public string Id { get; set; } = ""; + public ActionStep Step { get; set; } = new(); + public double X { get; set; } + public double Y { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool Disabled { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool IgnoreErrors { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public ActionInputMode Inputs { get; set; } +} + +public sealed class ActionEdge { + public const string ElsePort = "else"; + + public string From { get; set; } = ""; + public string To { get; set; } = ""; + public string? Port { get; set; } +} + +public sealed class ActionGraph { + public static readonly JsonSerializerOptions JsonOptions = new() { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + public ActionRepeatMode OnRepeat { get; set; } = ActionRepeatMode.Restart; + + [JsonIgnore] public bool HasTriggers => Nodes.Any(n => n.Step.Type == ActionStepType.Trigger); + + [JsonIgnore] + public ActionRepeatMode EffectiveRepeat => + HasTriggers && !AllowedWithTriggers(OnRepeat) ? ActionRepeatMode.Parallel : OnRepeat; + + public List Nodes { get; set; } = []; + public List Edges { get; set; } = []; + + public static bool AllowedWithTriggers(ActionRepeatMode mode) { + return mode is ActionRepeatMode.Parallel or ActionRepeatMode.Queue; + } + + public static ActionGraph Sequence(params ActionStep[] steps) { + var graph = new ActionGraph(); + for (var i = 0; i < steps.Length; i++) { + var id = $"{i + 1}"; + graph.Nodes.Add(new ActionNode { Id = id, Step = steps[i], X = i * 220 }); + if (i > 0) graph.Edges.Add(new ActionEdge { From = $"{i}", To = id }); + } + + return graph; + } + + public IEnumerable Incoming(string nodeId) { + return Edges.Where(e => e.To == nodeId).Select(e => e.From); + } + + public IEnumerable Outgoing(string nodeId) { + return Edges.Where(e => e.From == nodeId).Select(e => e.To); + } + + public IEnumerable IncomingEdges(string nodeId) { + return Edges.Where(e => e.To == nodeId); + } + + public HashSet OutputVariables() { + return Nodes.Where(n => !n.Disabled && !string.IsNullOrEmpty(n.Step.OutputVariable)) + .Select(n => n.Step.OutputVariable!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + public HashSet Upstream(string nodeId) { + var seen = new HashSet(StringComparer.Ordinal); + var stack = new Stack(Incoming(nodeId)); + while (stack.TryPop(out string? id)) + if (seen.Add(id)) + foreach (string previous in Incoming(id)) + stack.Push(previous); + return seen; + } + + public bool WouldLoop(string from, string to) { + if (from == to) return true; + var seen = new HashSet(StringComparer.Ordinal); + var stack = new Stack([to]); + while (stack.TryPop(out string? id)) { + if (id == from) return true; + if (!seen.Add(id)) continue; + foreach (string next in Outgoing(id)) stack.Push(next); + } + + return false; + } + + public bool IsValid(out string error) { + error = ""; + var ids = new HashSet(StringComparer.Ordinal); + foreach (ActionNode node in Nodes) { + if (string.IsNullOrWhiteSpace(node.Id) || !ids.Add(node.Id)) { + error = $"Duplicate or empty node id '{node.Id}'"; + return false; + } + + if (!node.Disabled && !node.Step.IsValid(out string stepError)) { + error = stepError; + return false; + } + } + + var links = new HashSet<(string, string)>(); + foreach (ActionEdge edge in Edges) { + if (!ids.Contains(edge.From) || !ids.Contains(edge.To) || edge.From == edge.To) { + error = $"Connection {edge.From} -> {edge.To} is not between two different steps"; + return false; + } + + if (!links.Add((edge.From, edge.To))) { + error = $"Steps {edge.From} and {edge.To} are connected twice"; + return false; + } + + if (Nodes.First(n => n.Id == edge.To).Step.Type == ActionStepType.Trigger) { + error = "Triggers start an action, you cannot lead into them"; + return false; + } + + if (edge.Port == null) continue; + if (edge.Port == ActionEdge.ElsePort + && Nodes.First(n => n.Id == edge.From).Step.Type == ActionStepType.Condition) continue; + error = $"Connection {edge.From} -> {edge.To} uses an output its step does not have"; + return false; + } + + var outputs = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (ActionNode node in Nodes.Where(node => !node.Disabled && + !string.IsNullOrEmpty(node.Step.OutputVariable) && + !outputs.Add(node.Step.OutputVariable))) { + error = $"More than one step saves its response as %{node.Step.OutputVariable}%"; + // should we let people have a toggle to allow overriding vars that are local to an action? + return false; + } + + Dictionary inDegree = ids.ToDictionary(id => id, id => Incoming(id).Count()); + var queue = new Queue(inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key)); + var visited = 0; + while (queue.TryDequeue(out string? id)) { + visited++; + foreach (string next in Outgoing(id)) + if (--inDegree[next] == 0) + queue.Enqueue(next); + } + + if (visited == ids.Count) return true; + error = "Steps are connected in a loop"; + return false; + } + + public string Describe() { + if (Nodes.Count == 0) return "No steps"; + bool linear = Nodes.All(n => Incoming(n.Id).Count() <= 1 && Outgoing(n.Id).Count() <= 1) + && Nodes.Count(n => !Incoming(n.Id).Any()) == 1; + if (!linear) return $"{Nodes.Count} steps"; + + var ordered = new List(); + ActionNode? current = Nodes.First(n => !Incoming(n.Id).Any()); + while (current != null && ordered.Count < Nodes.Count) { + ordered.Add(current); + string? nextId = Outgoing(current.Id).FirstOrDefault(); + current = nextId == null ? null : Nodes.FirstOrDefault(n => n.Id == nextId); + } + + return string.Join(" -> ", ordered.Select(n => n.Disabled ? $"({n.Step.Describe()}, off)" : n.Step.Describe())); + } + + public string ToJson() { + return JsonSerializer.Serialize(this, JsonOptions); + } + + public static bool TryParse(string? json, [NotNullWhen(true)] out ActionGraph? graph) { + graph = null; + if (string.IsNullOrWhiteSpace(json)) return false; + try { + graph = JsonSerializer.Deserialize(json, JsonOptions); + return graph != null; + } + catch (JsonException) { + return false; + } + } +} + +public sealed class CustomAction { + public const int CurrentFormatVersion = 1; + public const string FileExtension = ".sma"; + + private static readonly JsonSerializerOptions FileOptions = new(ActionGraph.JsonOptions) { WriteIndented = true }; + + public int FormatVersion { get; set; } = CurrentFormatVersion; + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = "New Action"; + public string? Author { get; set; } + public string? Description { get; set; } + public string Version { get; set; } = "1.0.0"; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool Disabled { get; set; } + + public ActionGraph Graph { get; set; } = new(); + + public string ToJson() { + return JsonSerializer.Serialize(this, FileOptions); + } + + public CustomAction Clone() { + return TryParse(ToJson(), out CustomAction? copy) ? copy : new CustomAction(); + } + + public static bool TryParse(string? json, [NotNullWhen(true)] out CustomAction? action) { + action = null; + if (string.IsNullOrWhiteSpace(json)) return false; + try { + action = JsonSerializer.Deserialize(json, FileOptions); + return action != null && action.Id != Guid.Empty; + } + catch (JsonException) { + return false; + } + } +} + +public sealed record ActionContext( + SubathonEventSource Source, + string User, + string RepeatKey, + string? Label = null, + SubathonTrigger? Trigger = null, + IReadOnlySet? TriggerNodes = null, + IReadOnlyDictionary? TriggerValues = null, + int Depth = 0); + +public sealed class ActionRunProgress { + public const int MaxVariableLength = 64 * 1024 * 2; + + public const string TriggerVariable = "trigger"; + + // a step saving to %resp% also sets %resp_status% + public const string StatusSuffix = "_status"; + private readonly Lock _lock = new(); + + public HashSet Done { get; set; } = []; + public HashSet Skipped { get; set; } = []; + public Dictionary Ports { get; set; } = []; + + public Dictionary Fired { get; set; } = []; + public Dictionary Variables { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary Values { get; set; } = []; + + public void SetOutput(string name, string value, string status) { + SetVariable(name, value); + SetVariable($"{name}{StatusSuffix}", status); + } + + public bool IsDone(string nodeId) { + lock (_lock) { + return Done.Contains(nodeId); + } + } + + public void MarkDone(string nodeId) { + lock (_lock) { + Done.Add(nodeId); + } + } + + public bool IsResolved(string nodeId) { + lock (_lock) { + return Done.Contains(nodeId) || Skipped.Contains(nodeId); + } + } + + public void MarkSkipped(string nodeId) { + lock (_lock) { + Skipped.Add(nodeId); + } + } + + public void SetPort(string nodeId, string? port) { + lock (_lock) { + if (port == null) Ports.Remove(nodeId); + else Ports[nodeId] = port; + } + } + + private static string FireKey(string nodeId, string? port) { + return $"{nodeId}|{port}"; + } + + public void RecordRun(string nodeId, string? port) { + lock (_lock) { + string key = FireKey(nodeId, port); + Fired[key] = Fired.GetValueOrDefault(key) + 1; + } + } + + public int Runs(string nodeId) { + var prefix = $"{nodeId}|"; + lock (_lock) { + return Fired.Where(f => f.Key.StartsWith(prefix, StringComparison.Ordinal)).Sum(f => f.Value); + } + } + + public int Firings(ActionEdge edge) { + var prefix = $"{edge.From}|"; + lock (_lock) { + if (Fired.TryGetValue(FireKey(edge.From, edge.Port), out int count)) return count; + if (Fired.Keys.Any(k => k.StartsWith(prefix, StringComparison.Ordinal))) return 0; + return Done.Contains(edge.From) && Ports.GetValueOrDefault(edge.From) == edge.Port ? 1 : 0; + } + } + + public bool IsLive(ActionEdge edge) { + return Firings(edge) > 0; + } + + public void SetVariable(string name, string value) { + if (value.Length > MaxVariableLength) value = value[..MaxVariableLength]; + lock (_lock) { + Variables[name] = value; + } + } + + public bool TryReadVariable(string token, out string value) { + string root = ActionStepTypeHelper.TokenRoot(token); + string? raw; + lock (_lock) { + if (!Variables.TryGetValue(root, out raw)) { + value = ""; + return false; + } + } + + value = root.Length == token.Length ? raw : ActionStepTypeHelper.ReadJsonPath(raw, token[root.Length..]); + return true; + } + + public bool TryGetValue(string key, out double value) { + lock (_lock) { + return Values.TryGetValue(key, out value); + } + } + + public void Remember(string key, double value) { + lock (_lock) { + Values.TryAdd(key, value); + } + } + + public string ToJson() { + lock (_lock) { + return JsonSerializer.Serialize(this, ActionGraph.JsonOptions); + } + } + + public static ActionRunProgress Parse(string? json) { + if (string.IsNullOrWhiteSpace(json)) return new ActionRunProgress(); + try { + ActionRunProgress progress = JsonSerializer.Deserialize(json, ActionGraph.JsonOptions) + ?? new ActionRunProgress(); + progress.Variables = new Dictionary(progress.Variables, StringComparer.OrdinalIgnoreCase); + return progress; + } + catch (JsonException) { + return new ActionRunProgress(); + } + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Objects/SubathonTriggerWatcher.cs b/SubathonManager.Core/Objects/SubathonTriggerWatcher.cs new file mode 100644 index 00000000..c9e0a2f5 --- /dev/null +++ b/SubathonManager.Core/Objects/SubathonTriggerWatcher.cs @@ -0,0 +1,280 @@ +using System.Globalization; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Models; + +namespace SubathonManager.Core.Objects; + +public sealed class SubathonTriggerWatcher(Action, SubathonEvent?> fire) { + private readonly Lock _lock = new(); + private bool _lastLocked; + private MultiplierSnapshot? _lastMultiplier; + private bool _lastPaused; + private bool _lastEnded; + private Guid? _trackedSubathonId; + private bool _started; + + public void Start() { + Stop(); + SubathonEvents.SubathonEventProcessed += OnSubathonEventProcessed; + SubathonEvents.SubathonDataUpdate += OnSubathonDataUpdate; + SubathonEvents.SubathonGoalCompleted += OnGoalCompleted; + SubathonEvents.PromptRunStarted += OnPromptRunStarted; + SubathonEvents.PromptRunUpdate += OnPromptRunUpdate; + WheelEvents.WheelSpinStarted += OnWheelSpinStarted; + WheelEvents.WheelSpinResult += OnWheelSpinResult; + _started = true; + } + + public void Stop() { + SubathonEvents.SubathonEventProcessed -= OnSubathonEventProcessed; + SubathonEvents.SubathonDataUpdate -= OnSubathonDataUpdate; + SubathonEvents.SubathonGoalCompleted -= OnGoalCompleted; + SubathonEvents.PromptRunStarted -= OnPromptRunStarted; + SubathonEvents.PromptRunUpdate -= OnPromptRunUpdate; + WheelEvents.WheelSpinStarted -= OnWheelSpinStarted; + WheelEvents.WheelSpinResult -= OnWheelSpinResult; + if (!_started) return; + _started = false; + lock (_lock) { + _trackedSubathonId = null; + _lastMultiplier = null; + } + } + + private void OnSubathonEventProcessed(SubathonEvent subathonEvent, bool effective) { + fire(SubathonTrigger.SubathonEvent, EventValues(subathonEvent), subathonEvent); + } + + private void OnSubathonDataUpdate(SubathonData subathon, DateTime timestamp) { + var fired = new List<(SubathonTrigger, Dictionary)>(3); + MultiplierSnapshot? multiplier = subathon.Multiplier?.SubathonId == subathon.Id + ? MultiplierSnapshot.From(subathon.Multiplier) : null; + + lock (_lock) { + if (_trackedSubathonId != subathon.Id) { + _trackedSubathonId = subathon.Id; + _lastPaused = subathon.IsPaused; + _lastLocked = subathon.IsLocked; + _lastEnded = HasEnded(subathon); + _lastMultiplier = multiplier; + return; + } + + if (subathon.IsPaused != _lastPaused) { + _lastPaused = subathon.IsPaused; + fired.Add((subathon.IsPaused ? SubathonTrigger.TimerPaused : SubathonTrigger.TimerResumed, + TimerValues(subathon))); + } + + if (subathon.IsLocked != _lastLocked) { + _lastLocked = subathon.IsLocked; + fired.Add((subathon.IsLocked ? SubathonTrigger.TimerLocked : SubathonTrigger.TimerUnlocked, + TimerValues(subathon))); + } + + bool ended = HasEnded(subathon); + if (ended != _lastEnded) { + _lastEnded = ended; + if (ended) fired.Add((SubathonTrigger.TimerEnded, TimerValues(subathon))); + } + + if (multiplier != null) { + MultiplierSnapshot? previous = _lastMultiplier; + _lastMultiplier = multiplier; + if (previous != null) { + if (multiplier.Running && multiplier != previous) + fired.Add((SubathonTrigger.MultiplierStarted, MultiplierValues(multiplier))); + else if (!multiplier.Running && previous.Running) + fired.Add((SubathonTrigger.MultiplierEnded, MultiplierValues(previous))); + } + } + } + + foreach ((SubathonTrigger trigger, Dictionary values) in fired) + fire(trigger, values, null); + } + + private void OnGoalCompleted(SubathonGoal goal, long currentValue) { + fire(SubathonTrigger.GoalCompleted, new Dictionary { + ["goaltext"] = goal.Text, + ["goaltarget"] = Number(goal.Points), + ["goalcurrent"] = Number(currentValue) + }, null); + } + + private void OnWheelSpinStarted(WheelSet wheel, int delaySeconds) { + fire(SubathonTrigger.WheelSpinStart, new Dictionary { + ["wheelname"] = wheel.Name, + ["wheelid"] = wheel.Id.ToString(), + ["spindelay"] = Number(delaySeconds) + }, null); + } + + private void OnWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int spinsOwed) { + fire(SubathonTrigger.WheelSpinEnd, new Dictionary { + ["wheelname"] = wheel.Name, + ["wheelid"] = wheel.Id.ToString(), + ["wheelitem"] = item?.Text ?? "", + ["spinstatus"] = history.Status.ToString(), + ["spinsowed"] = Number(spinsOwed) + }, null); + } + + private void OnPromptRunStarted(SubathonPromptRun run, SubathonPrompt? prompt) { + fire(SubathonTrigger.PromptStarted, PromptValues(run, prompt), null); + } + + private void OnPromptRunUpdate(SubathonPromptRun run, SubathonPrompt? prompt) { + if (run.IsActive) return; + fire(SubathonTrigger.PromptEnded, PromptValues(run, prompt), null); + } + + private static bool HasEnded(SubathonData subathon) { + return !subathon.IsSubathonReversed() && subathon.MillisecondsRemaining() <= 0; + } + + private static string Number(IFormattable value) { + return value.ToString(null, CultureInfo.InvariantCulture); + } + + public static Dictionary EventValues(SubathonEvent subathonEvent) { + var eventType = $"{subathonEvent.EventType}"; + string? trueSource = subathonEvent.EventType.GetTypeTrueSource(subathonEvent.EventTypeMeta); + if (subathonEvent.EventType == SubathonEventType.GoAffProOrder + && GoAffProOrderHelper.TryGetStore(subathonEvent.EventTypeMeta, out GoAffProStore? store)) { + trueSource = store.InternalName; + eventType = store.InternalEventName; + } + + double seconds = subathonEvent.GetFinalSecondsValueRaw() < 0.5 ? 0 : subathonEvent.GetFinalSecondsValue(); + return new Dictionary { + ["eventtype"] = eventType, + ["source"] = $"{subathonEvent.Source}", + ["truesource"] = trueSource ?? "", + ["subtype"] = $"{subathonEvent.EventType.GetSubType()}", + ["user"] = subathonEvent.User ?? "", + ["value"] = subathonEvent.Value, + ["amount"] = Number(subathonEvent.Amount), + ["currency"] = subathonEvent.Currency ?? "", + ["command"] = $"{subathonEvent.Command}", + ["secondsadded"] = Number(seconds), + ["pointsadded"] = Number(subathonEvent.GetFinalPointsValue()), + ["secondaryvalue"] = subathonEvent.SecondaryValue, + ["tertiaryvalue"] = subathonEvent.TertiaryValue, + ["reversed"] = $"{subathonEvent.WasReversed}" + }; + } + + public static Dictionary TimerValues(SubathonData subathon) { + TimeSpan remaining = subathon.TimeRemainingRounded(); + return new Dictionary { + ["timeremaining"] = $"{(int)remaining.TotalHours:00}:{remaining.Minutes:00}:{remaining.Seconds:00}", + ["secondsremaining"] = Number((long)remaining.TotalSeconds), + ["points"] = Number(subathon.Points), + ["paused"] = $"{subathon.IsPaused}", + ["locked"] = $"{subathon.IsLocked}" + }; + } + + public static Dictionary MultiplierValues(MultiplierSnapshot multiplier) { + return new Dictionary { + ["multiplier"] = Number(multiplier.Multiplier), + ["multipliertime"] = $"{multiplier.Time}", + ["multiplierpoints"] = $"{multiplier.Points}", + ["multiplierduration"] = Number((long)(multiplier.Duration?.TotalSeconds ?? 0)), + ["multiplierhypetrain"] = $"{multiplier.FromHypeTrain}" + }; + } + + public static Dictionary PromptValues(SubathonPromptRun run, SubathonPrompt? prompt) { + prompt ??= run.LinkedPrompt; + return new Dictionary { + ["prompttext"] = prompt?.Text ?? "", + ["prompttype"] = $"{prompt?.Type}", + ["prompttarget"] = Number(prompt?.Value ?? run.SnapshotTargetValue), + ["promptduration"] = + Number((long)(prompt?.CompletionDuration.TotalSeconds ?? (run.ExpiresAt - run.StartedAt).TotalSeconds)), + ["promptstatus"] = $"{run.Status}" + }; + } + + public static Dictionary SampleValues(SubathonTrigger trigger) { + switch (trigger) { + case SubathonTrigger.SubathonEvent: + return EventValues(new SubathonEvent { + Source = SubathonEventSource.Simulated, + EventType = SubathonEventType.ExternalDonation, + User = "TestUser", + Value = "5", + Currency = "USD", + SecondsValue = 600, + PointsValue = 5, + ProcessedToSubathon = true + }); + case SubathonTrigger.MultiplierStarted: + case SubathonTrigger.MultiplierEnded: + return MultiplierValues(new MultiplierSnapshot(trigger == SubathonTrigger.MultiplierStarted, + 2, true, true, TimeSpan.FromMinutes(10), DateTime.Now, false)); + case SubathonTrigger.TimerPaused: + case SubathonTrigger.TimerResumed: + case SubathonTrigger.TimerLocked: + case SubathonTrigger.TimerUnlocked: + case SubathonTrigger.TimerEnded: + return new Dictionary { + ["timeremaining"] = "12:34:56", + ["secondsremaining"] = "45296", + ["points"] = "250", + ["paused"] = (trigger == SubathonTrigger.TimerPaused).ToString(), + ["locked"] = (trigger == SubathonTrigger.TimerLocked).ToString() + }; + case SubathonTrigger.GoalCompleted: + return new Dictionary { + ["goaltext"] = "Test Goal", + ["goaltarget"] = "100", + ["goalcurrent"] = "100" + }; + case SubathonTrigger.WheelSpinStart: + return new Dictionary { + ["wheelname"] = "Test Wheel", + ["wheelid"] = Guid.Empty.ToString(), + ["spindelay"] = "0" + }; + case SubathonTrigger.WheelSpinEnd: + return new Dictionary { + ["wheelname"] = "Test Wheel", + ["wheelid"] = Guid.Empty.ToString(), + ["wheelitem"] = "Test Item", + ["spinstatus"] = "Pending", + ["spinsowed"] = "0" + }; + case SubathonTrigger.PromptStarted: + case SubathonTrigger.PromptEnded: + return new Dictionary { + ["prompttext"] = "Test Prompt", + ["prompttype"] = $"{SubathonPromptType.Points}", + ["prompttarget"] = "10", + ["promptduration"] = "300", + ["promptstatus"] = trigger == SubathonTrigger.PromptStarted + ? $"{SubathonPromptRunStatus.Active}" + : $"{SubathonPromptRunStatus.Completed}" + }; + default: + return new Dictionary(); + } + } +} + +public sealed record MultiplierSnapshot( + bool Running, + double Multiplier, + bool Time, + bool Points, + TimeSpan? Duration, + DateTime? Started, + bool FromHypeTrain) { + public static MultiplierSnapshot From(MultiplierData data) { + return new MultiplierSnapshot(data.IsRunning(), data.Multiplier, data.ApplyToSeconds, + data.ApplyToPoints, data.Duration, data.Started, data.FromHypeTrain); + } +} diff --git a/SubathonManager.Core/Objects/VTSWheelAction.cs b/SubathonManager.Core/Objects/VTSWheelAction.cs index 57449b1b..81978ccb 100644 --- a/SubathonManager.Core/Objects/VTSWheelAction.cs +++ b/SubathonManager.Core/Objects/VTSWheelAction.cs @@ -163,16 +163,62 @@ public string Describe() { _ => AfterHotkey.ToString() }; - return $"{Target} {initial} for {FormatDuration(Duration)} then {after}"; + return $"{Target} {initial} for {Utils.FormatShortDuration(Duration)} then {after}"; } - private static string FormatDuration(TimeSpan duration) { - if (duration.TotalHours >= 1) return $"{(int)duration.TotalHours}h{duration.Minutes:00}m"; - if (duration.TotalMinutes >= 1) return $"{(int)duration.TotalMinutes}m{duration.Seconds:00}s"; - return $"{(int)duration.TotalSeconds}s"; + // migration path + public ActionGraph ToActionGraph() { + ActionStepType type = Kind switch { + VtsTargetKind.Parameter => ActionStepType.VtsParameter, + VtsTargetKind.Hotkey => ActionStepType.VtsHotkey, + _ => ActionStepType.VtsExpression + }; + + var initial = new ActionStep { + Type = type, + Target = Target.Trim(), + Operation = Kind switch { + VtsTargetKind.Expression => ToToggleOp(ToggleAction), + VtsTargetKind.Parameter => ActionOperation.Hold, + _ => ActionOperation.Trigger + }, + Value = Kind == VtsTargetKind.Parameter ? Value : null + }; + + // held parameter with no duration stays held until something else releases it + if (!HasRevert) return ActionGraph.Sequence(initial); + + var after = new ActionStep { Type = type, Target = initial.Target }; + switch (Kind) { + case VtsTargetKind.Expression: + after.Operation = ToToggleOp(AfterToggle); + break; + case VtsTargetKind.Parameter: + after.Operation = AfterParameter switch { + VtsParameterAfterAction.ResetToOriginal => ActionOperation.Restore, + VtsParameterAfterAction.SetNewValue => ActionOperation.Set, + _ => ActionOperation.Release + }; + after.Value = AfterParameter == VtsParameterAfterAction.SetNewValue ? AfterValue : null; + break; + default: + after.Operation = ActionOperation.Trigger; + break; + } + + var wait = new ActionStep { Type = ActionStepType.Wait, Seconds = Duration.TotalSeconds }; + return ActionGraph.Sequence(initial, wait, after); + + static ActionOperation ToToggleOp(VtsToggleAction action) { + return action switch { + VtsToggleAction.Off => ActionOperation.Off, + VtsToggleAction.Toggle => ActionOperation.Toggle, + _ => ActionOperation.On + }; + } } private static bool TryParseNumber(string text, out double value) { - return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + return Utils.TryParseAmount(text, out value); } } \ No newline at end of file diff --git a/SubathonManager.Core/Objects/WidgetMeta.cs b/SubathonManager.Core/Objects/WidgetMeta.cs index b0f6e00e..435a6345 100644 --- a/SubathonManager.Core/Objects/WidgetMeta.cs +++ b/SubathonManager.Core/Objects/WidgetMeta.cs @@ -11,6 +11,36 @@ public class WidgetMeta { public int Width { get; set; } = 300; public int Height { get; set; } = 300; public Dictionary Vars { get; set; } = new(); + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WidgetMetaGlobals? Globals { get; set; } +} + +[ExcludeFromCodeCoverage] +public class WidgetMetaGlobals { + public bool All { get; set; } + + public Dictionary Vars { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public string ToWidgetValue() { + return Models.Widget.JoinGlobalVars(All, Vars.Keys); + } + + public static WidgetMetaGlobals? From(Models.Widget widget, IReadOnlyDictionary? types, + WidgetMetaGlobals? previous = null) { + if (string.IsNullOrWhiteSpace(widget.GlobalVars)) return null; + + var result = new WidgetMetaGlobals { All = widget.ListensToAllGlobals }; + foreach (string name in widget.GlobalVarNames) { + ActionValueType? old = previous?.Vars.FirstOrDefault(v => + string.Equals(v.Key, name, StringComparison.OrdinalIgnoreCase)).Value; + + result.Vars[name] = types != null && types.TryGetValue(name, out ActionValueType type) + ? type : old ?? ActionValueType.Text; + } + + return result; + } } [ExcludeFromCodeCoverage] diff --git a/SubathonManager.Core/ScheduleCsv.cs b/SubathonManager.Core/ScheduleCsv.cs index f7dd546e..56c6f101 100644 --- a/SubathonManager.Core/ScheduleCsv.cs +++ b/SubathonManager.Core/ScheduleCsv.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Text; using SubathonManager.Core.Enums; using SubathonManager.Core.Models; @@ -19,31 +18,28 @@ public static class ScheduleCsv { ]; public static string Write(IEnumerable items) { - var sb = new StringBuilder(); - sb.AppendLine(string.Join(',', Columns)); - foreach (ScheduleItem item in items) - sb.AppendLine(string.Join(',', - item.Date.ToString(DateFormat, CultureInfo.InvariantCulture), - item.Kind.ToString(), - item.StartMinute is { } s ? ScheduleItem.FormatMinute(s) : "", - item.EndMinute is { } e ? ScheduleItem.FormatMinute(e) : "", - Utils.EscapeCsv(item.Title), - Utils.EscapeCsv(item.Description), - item.IsDone ? "true" : "false")); - return sb.ToString(); + return CsvUtils.Write([Columns], items, item => [ + item.Date.ToString(DateFormat, CultureInfo.InvariantCulture), + item.Kind, + item.StartMinute is { } s ? ScheduleItem.FormatMinute(s) : "", + item.EndMinute is { } e ? ScheduleItem.FormatMinute(e) : "", + item.Title, + item.Description, + item.IsDone ? "true" : "false" + ]); } public static ParseResult Parse(string csv) { var items = new List(); var errors = new List(); - List<(int line, List fields)> records = ReadRecords(csv); + List<(int Line, string[] Fields)> records = CsvUtils.ReadRecords(csv); if (records.Count == 0) { errors.Add("File is empty."); return new ParseResult(items, errors); } - Dictionary index = records[0].fields + Dictionary index = records[0].Fields .Select((name, i) => (name: name.Trim(), i)) .GroupBy(x => x.name, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First().i, StringComparer.OrdinalIgnoreCase); @@ -53,11 +49,11 @@ public static ParseResult Parse(string csv) { errors.Add($"Missing required column \"{required}\"."); if (errors.Count > 0) return new ParseResult(items, errors); - string Field(List fields, string column) { - return index.TryGetValue(column, out int i) && i < fields.Count ? fields[i] : ""; + string Field(string[] fields, string column) { + return index.TryGetValue(column, out int i) && i < fields.Length ? fields[i] : ""; } - foreach ((int line, List fields) in records.Skip(1)) { + foreach ((int line, string[] fields) in records.Skip(1)) { if (fields.All(string.IsNullOrWhiteSpace)) continue; string dateRaw = Field(fields, nameof(ScheduleItem.Date)).Trim(); @@ -152,71 +148,6 @@ private static bool IsSameSlot(ScheduleItem a, ScheduleItem b) { a.StartMinute == b.StartMinute && a.EndMinute == b.EndMinute; } - private static List<(int line, List fields)> ReadRecords(string csv) { - var records = new List<(int, List)>(); - var fields = new List(); - var field = new StringBuilder(); - var inQuotes = false; - var line = 1; - var recordStart = 1; - var any = false; - - if (csv.Length > 0 && csv[0] == (char)0xFEFF) csv = csv[1..]; - - for (var i = 0; i < csv.Length; i++) { - char c = csv[i]; - if (inQuotes) { - if (c == '"' && i + 1 < csv.Length && csv[i + 1] == '"') { - field.Append('"'); - i++; - } - else if (c == '"') { - inQuotes = false; - } - else { - if (c == '\n') line++; - if (c != '\r') field.Append(c); - } - - continue; - } - - switch (c) { - case '"': - inQuotes = true; - any = true; - break; - case ',': - fields.Add(field.ToString()); - field.Clear(); - any = true; - break; - case '\r': - break; - case '\n': - fields.Add(field.ToString()); - field.Clear(); - if (any || fields.Count > 1 || fields[0].Length > 0) records.Add((recordStart, fields)); - fields = []; - any = false; - line++; - recordStart = line; - break; - default: - field.Append(c); - any = true; - break; - } - } - - if (any || field.Length > 0) { - fields.Add(field.ToString()); - records.Add((recordStart, fields)); - } - - return records; - } - public sealed record ParseResult(List Items, List Errors); public sealed record ImportPlan(List ToAdd, List ToUpdate, int Unchanged); diff --git a/SubathonManager.Core/Security/StorageKeys.cs b/SubathonManager.Core/Security/StorageKeys.cs index 2151af26..ae7f5e88 100644 --- a/SubathonManager.Core/Security/StorageKeys.cs +++ b/SubathonManager.Core/Security/StorageKeys.cs @@ -3,6 +3,9 @@ public static class StorageKeys { public const string TwitchAccessToken = "SM.Twitch.AccessToken"; + // + lowercase secret name + public const string ActionSecretPrefix = "SM.Actions.Secrets."; + public const string FourthWallAccessToken = "SM.FourthWall.AccessToken"; public const string FourthWallRefreshToken = "SM.FourthWall.RefreshToken"; @@ -33,4 +36,10 @@ public static class StorageKeys { public const string TiltifyAccessToken = "SM.Tiltify.AccessToken"; public const string TiltifyRefreshToken = "SM.Tiltify.RefreshToken"; public const string TiltifyTokenExpiry = "SM.Tiltify.TokenExpiry"; + + public const string PatreonAccessToken = "SM.Patreon.AccessToken"; + public const string PatreonRefreshToken = "SM.Patreon.RefreshToken"; + public const string PatreonTokenExpiry = "SM.Patreon.TokenExpiry"; + public const string PatreonWebhookId = "SM.Patreon.WebhookId"; + public const string PatreonWebhookSecret = "SM.Patreon.WebhookSecret"; } \ No newline at end of file diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index deee597e..51f347c8 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -13,7 +13,7 @@ namespace SubathonManager.Core; -public static class Utils { +public static partial class Utils { public static readonly Dictionary DonationSettings = new(); private static readonly ConcurrentDictionary<(SubathonEventSource Source, string Service), IntegrationConnection> @@ -21,6 +21,7 @@ public static class Utils { public static string? PendingOverlayImportPath { get; set; } public static string? PendingWidgetPackImportPath { get; set; } + public static string? PendingActionImportPath { get; set; } public static IEnumerable GetAllConnections() { return ConnectionDetails.Values; @@ -119,6 +120,13 @@ private static TimeSpan ParseLetterDurationString(string input) { return new TimeSpan(days, hours, minutes, seconds); } + public static string FormatShortDuration(TimeSpan duration) { + if (duration.TotalHours >= 1) return $"{(int)duration.TotalHours}h{duration.Minutes:00}m"; + if (duration.TotalMinutes >= 1) return $"{(int)duration.TotalMinutes}m{duration.Seconds:00}s"; + if (duration.TotalSeconds >= 1) return $"{(int)duration.TotalSeconds}s"; + return $"{(int)duration.TotalMilliseconds}ms"; + } + public static Guid TryParseGuid(string? value) { if (value != null && Guid.TryParse(value, out Guid g)) return g; return CreateGuidFromUniqueString(value ?? Guid.NewGuid().ToString()); @@ -138,14 +146,16 @@ public static Guid CreateGuidFromUniqueString(string? key) { return new Guid(guidBytes); } - // handles locals where it may be "12,50" instead of "12.50" public static bool TryParseAmount(string? text, out double value) { value = 0; if (string.IsNullOrWhiteSpace(text)) return false; text = text.Trim(); - return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value) || - double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.CurrentCulture, - out value); + if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value)) return true; + if (DecimalValueCultureCommaRegex().IsMatch(text)) + return double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, + out value); + return double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.CurrentCulture, + out value); } public static double ParseAmount(string? text) { @@ -154,7 +164,7 @@ public static double ParseAmount(string? text) { public static string TryParseCurrency(string amountString) { var currency = ""; - Match match = Regex.Match(amountString, @"^(?[A-Z]{3})(?![A-Z])"); + Match match = CurrencyCodeCheckRegex().Match(amountString); if (match.Success) { currency = match.Groups["code"].Value; } @@ -168,44 +178,34 @@ public static string TryParseCurrency(string amountString) { else if (amountString.StartsWith("NT$")) currency = "TWD"; else if (amountString.StartsWith("NZ$")) currency = "NZD"; } - else if (amountString.Contains("₨")) { + else if (amountString.Contains('₨')) { if (amountString.StartsWith("PK₨")) currency = "PKR"; else if (amountString.StartsWith("LK₨")) currency = "LKR"; else if (amountString.StartsWith("MU₨")) currency = "MUR"; else if (amountString.StartsWith("NP₨")) currency = "NPR"; } else { - if (amountString.StartsWith("₩")) currency = "KRW"; - else if (amountString.StartsWith("₱")) currency = "PHP"; - else if (amountString.StartsWith("₫")) currency = "VND"; - else if (amountString.StartsWith("₦")) currency = "NGN"; - else if (amountString.StartsWith("₴")) currency = "UAH"; - else if (amountString.StartsWith("₲")) currency = "PYG"; // - else if (amountString.StartsWith("₡")) currency = "CRC"; - else if (amountString.StartsWith("₺")) currency = "TRY"; - else if (amountString.StartsWith("₼")) currency = "AZN"; - else if (amountString.StartsWith("₸")) currency = "KZT"; - else if (amountString.StartsWith("₭")) currency = "LAK"; - else if (amountString.StartsWith("₾")) currency = "GEL"; - else if (amountString.StartsWith("₮")) currency = "MNT"; - else if (amountString.StartsWith("₹")) currency = "INR"; - else if (amountString.StartsWith("₣")) currency = "CHF"; + if (amountString.StartsWith('₩')) currency = "KRW"; + else if (amountString.StartsWith('₱')) currency = "PHP"; + else if (amountString.StartsWith('₫')) currency = "VND"; + else if (amountString.StartsWith('₦')) currency = "NGN"; + else if (amountString.StartsWith('₴')) currency = "UAH"; + else if (amountString.StartsWith('₲')) currency = "PYG"; // + else if (amountString.StartsWith('₡')) currency = "CRC"; + else if (amountString.StartsWith('₺')) currency = "TRY"; + else if (amountString.StartsWith('₼')) currency = "AZN"; + else if (amountString.StartsWith('₸')) currency = "KZT"; + else if (amountString.StartsWith('₭')) currency = "LAK"; + else if (amountString.StartsWith('₾')) currency = "GEL"; + else if (amountString.StartsWith('₮')) currency = "MNT"; + else if (amountString.StartsWith('₹')) currency = "INR"; + else if (amountString.StartsWith('₣')) currency = "CHF"; } } return currency; } - public static string EscapeCsv(string? value) { - if (string.IsNullOrWhiteSpace(value)) return ""; - if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) { - value = value.Replace("\"", "\"\""); - return $"\"{value}\""; - } - - return value; - } - public static string DescribeTokenPointRate(string? pointsPer100, string unitPlural, string? unitSingular = null) { if (!decimal.TryParse(pointsPer100, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal points)) return ""; @@ -242,7 +242,7 @@ public static (bool, double) GetAltCurrencyUseAsDonation(IConfig config, Subatho if (!eventType.IsToken()) return (false, 1); if (eventType != SubathonEventType.TwitchCheer && eventType != SubathonEventType.PicartoTip) - double.TryParse(config.Get("Extensions", $"{eventType}.Modifier", "1"), out modifier); + TryParseAmount(config.Get("Extensions", $"{eventType}.Modifier", "1"), out modifier); bool useAsDonation = config.GetBool("Currency", "BitsLikeAsDonation"); return (useAsDonation, modifier); @@ -274,7 +274,26 @@ public static (bool, double) GetAltCurrencyUseAsDonation(IConfig config, Subatho } } + public static string? GetJsonString(JsonElement element, string property) { + return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(property, out JsonElement value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + // dates without an offset are taken as utc + public static DateTimeOffset? ParseDateFromString(string? value) { + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, + out DateTimeOffset parsed) + ? parsed + : null; + } + + public const string PerUnitMeta = "PER_UNIT"; + public static bool IsCommissionAsDonation(IConfig config, SubathonEvent ev) { + if (ev.EventType == SubathonEventType.PatreonPledge) + return config.GetBool(nameof(SubathonEventSource.Patreon),$"{ev.EventType}.CommissionAsDonation"); if (!ev.EventType.IsOrder()) return false; if (ev.EventType == SubathonEventType.GoAffProOrder) { @@ -374,4 +393,9 @@ public struct COPYDATASTRUCT { public IntPtr lpData; } } + + [GeneratedRegex(@"^[+-]?\d+,\d{1,2}$")] + private static partial Regex DecimalValueCultureCommaRegex(); + [GeneratedRegex(@"^(?[A-Z]{3})(?![A-Z])")] + private static partial Regex CurrencyCodeCheckRegex(); } \ No newline at end of file diff --git a/SubathonManager.Data/ActionVariableResolver.cs b/SubathonManager.Data/ActionVariableResolver.cs new file mode 100644 index 00000000..988ff58e --- /dev/null +++ b/SubathonManager.Data/ActionVariableResolver.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using Microsoft.EntityFrameworkCore; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; + +namespace SubathonManager.Data; + +public static class ActionVariableResolver { + + public static async Task FillAsync(IDbContextFactory factory, string? template, + ActionContext? ctx = null, IConfig? config = null) { + List used = ActionStepTypeHelper.FindVariables(template).Distinct().ToList(); + if (used.Count == 0) return template ?? ""; + Dictionary values = await ResolveAsync(factory, used, ctx, config); + return ActionStepTypeHelper.ReplaceVariables(template, values); + } + + public static async Task> ResolveAsync(IDbContextFactory factory, + IEnumerable variables, ActionContext? ctx = null, IConfig? config = null) { + List wanted = variables.Distinct().ToList(); + var values = new Dictionary(); + if (wanted.Count == 0) return values; + + SubathonData? subathon = null; + SubathonGoalSet? goals = null; + if (wanted.Any(v => v.GetGroup() != "Action")) { + await using AppDbContext db = await factory.CreateDbContextAsync(); + subathon = await db.SubathonDatas.Include(s => s.Multiplier).AsNoTracking() + .FirstOrDefaultAsync(s => s.IsActive); + if (wanted.Any(v => v.GetGroup() == "Goals")) + goals = await db.SubathonGoalSets.Include(g => g.Goals).AsNoTracking() + .FirstOrDefaultAsync(g => g.IsActive); + } + + // could get from subathondata, but sending both for verbosity + string defaultCurrency = (config?.Get("Currency", "Primary", "USD") ?? "USD").Trim(); + GoalState goal = GoalState.From(goals, subathon); + + foreach (ActionVariable variable in wanted) + values[variable] = variable switch { + ActionVariable.User => ctx?.User ?? "", + ActionVariable.Source => ctx != null ? $"{ctx.Source}" : "", + ActionVariable.Label => ctx?.Label ?? "", + ActionVariable.Now => DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + ActionVariable.DefaultCurrency => defaultCurrency, + ActionVariable.GoalText => goal.Current?.Text ?? "", + ActionVariable.GoalPoints => Number(goal.Current?.Points ?? 0), + ActionVariable.GoalIndex => Number(goal.CurrentIndex), + ActionVariable.GoalCount => Number(goal.Count), + ActionVariable.GoalProgress => Number(goal.Progress), + ActionVariable.LastGoalText => goal.Last?.Text ?? "", + ActionVariable.LastGoalPoints => Number(goal.Last?.Points ?? 0), + ActionVariable.GoalListName => goals?.Name ?? "", + ActionVariable.GoalType => $"{goals?.Type ?? GoalsType.Points}", + _ => subathon == null ? "" : FromSubathon(variable, subathon, defaultCurrency) + }; + + return values; + } + + private static string FromSubathon(ActionVariable variable, SubathonData subathon, string defaultCurrency) { + MultiplierData multiplier = subathon.Multiplier; + bool running = multiplier.IsRunning(); + TimeSpan remaining = subathon.TimeRemainingRounded(); + + return variable switch { + ActionVariable.SecondsRemaining => Number((long)remaining.TotalSeconds), + ActionVariable.TimeRemaining => + $"{(long)remaining.TotalHours:00}:{remaining.Minutes:00}:{remaining.Seconds:00}", + ActionVariable.SecondsElapsed => Number(subathon.MillisecondsElapsed / 1000), + ActionVariable.TimerPaused => Bool(subathon.IsPaused), + ActionVariable.TimerLocked => Bool(subathon.IsLocked), + ActionVariable.TimerReversed => Bool(subathon.IsSubathonReversed()), + ActionVariable.MultiplierActive => Bool(running), + ActionVariable.MultiplierAmount => running + ? multiplier.Multiplier.ToString(CultureInfo.InvariantCulture) : "1", + ActionVariable.MultiplierSecondsRemaining => Number(running && multiplier is + { Duration: { } duration, Started: { } started } + ? Math.Max(0, (long)(started + duration - DateTime.Now).TotalSeconds) : 0), + ActionVariable.MultiplierPoints => Bool(running && multiplier.ApplyToPoints), + ActionVariable.MultiplierTime => Bool(running && multiplier.ApplyToSeconds), + ActionVariable.Points => Number(subathon.Points), + ActionVariable.Money => subathon.GetRoundedMoneySumWithCents().ToString("0.00", CultureInfo.InvariantCulture), + ActionVariable.Currency => string.IsNullOrWhiteSpace(subathon.Currency) ? defaultCurrency : subathon.Currency, + _ => "" + }; + } + + private static string Number(long value) { + return value.ToString(CultureInfo.InvariantCulture); + } + + private static string Bool(bool value) { + return value ? "true" : "false"; + } + + private readonly record struct GoalState(SubathonGoal? Current, SubathonGoal? Last, int CurrentIndex, + int Count, long Progress) { + + public static GoalState From(SubathonGoalSet? set, SubathonData? subathon) { + if (set == null || subathon == null) return default; + + List ordered = set.Goals.OrderBy(g => g.Points).ToList(); + long progress = set.Type == GoalsType.Money ? subathon.GetRoundedMoneySum() : subathon.Points; + int next = ordered.FindIndex(g => g.Points > progress); + + SubathonGoal? last = ordered.LastOrDefault(g => g.Points <= progress); + return new GoalState(next >= 0 ? ordered[next] : null, last, next >= 0 ? next + 1 : 0, ordered.Count, + progress); + } + } +} diff --git a/SubathonManager.Data/DbContext.cs b/SubathonManager.Data/DbContext.cs index 70e51db0..6580e16f 100644 --- a/SubathonManager.Data/DbContext.cs +++ b/SubathonManager.Data/DbContext.cs @@ -1,6 +1,4 @@ -using System.Diagnostics; using System.Numerics; -using System.Text; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using SubathonManager.Core; @@ -52,6 +50,8 @@ public AppDbContext(DbContextOptions options) public DbSet ScheduleItems { get; set; } + public DbSet ActionGlobals { get; set; } + public DbSet WidgetCatalogEntries => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -103,6 +103,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() .HasIndex(i => i.Date); + modelBuilder.Entity() + .HasKey(g => new { g.Kind, g.Name }); + modelBuilder.Entity() + .Property(g => g.Name) + .UseCollation("NOCASE"); + modelBuilder.Entity() .HasMany(s => s.Goals) .WithOne(g => g.LinkedGoalSet) @@ -283,8 +289,12 @@ public static async Task> GetSubathonCurrencyEvents(AppDbCon .Select(s => s.SiteId.ToString()) .ToHashSet(); + bool includePatreon = Utils.DonationSettings.TryGetValue(nameof(SubathonEventType.PatreonPledge), + out bool patreonAsDonation) && patreonAsDonation; + events = events.Where(e => e.EventType != null && (e.EventType.IsCurrencyDonation() || + (includePatreon && e.EventType == SubathonEventType.PatreonPledge) || (e.EventType == SubathonEventType.GoAffProOrder && !string.IsNullOrEmpty(e.EventTypeMeta) && goAffProMetasToInclude.Contains(e.EventTypeMeta)) || @@ -294,75 +304,46 @@ public static async Task> GetSubathonCurrencyEvents(AppDbCon return events; } - public static async Task ActiveEventsToCsv(AppDbContext db) { + public static async Task ActiveEventsToCsv(AppDbContext db) { SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); - if (subathon == null) return; + if (subathon == null) return null; List events = await db.SubathonEvents.Where(ev => ev.SubathonId == subathon.Id) .ToListAsync(); - string exportDir = Path.Combine(Config.DataFolder, "exports"); - Directory.CreateDirectory(exportDir); - var filepath = $"{exportDir}/subathon-{subathon.Id}.csv"; - - var sb = new StringBuilder(); - sb.AppendLine( - "Id,Source,Type,Command,User,Seconds Value,Points Value,Value,Currency,Amount,Multiplier Seconds,Multiplier Points,Processed,Final Seconds Added,Final Points Added,Timestamp,Secondary Value,Event Meta Type, Event Meta Common Type"); - foreach (SubathonEvent e in events) { - string val = e.Value; - string? commonMeta = string.IsNullOrWhiteSpace(e.EventTypeMeta) ? "" : e.EventTypeMeta; - if (e.EventType is SubathonEventType.TwitchGiftSub or SubathonEventType.TwitchSub) { - val = e.Value switch { - "1000" => "T1", - "2000" => "T2", - "3000" => "T3", - _ => val - }; - commonMeta = val; - } - - if (e.EventType == SubathonEventType.GoAffProOrder - && GoAffProOrderHelper.TryGetStore(e.EventTypeMeta, out GoAffProStore? store)) - commonMeta = store.InternalName; - - sb.AppendLine(string.Join(",", - e.Id, - Utils.EscapeCsv(e.Source.ToString()), - Utils.EscapeCsv(e.EventType.ToString()), - Utils.EscapeCsv(e.Command.ToString()), - Utils.EscapeCsv(e.User), - e.SecondsValue, - e.PointsValue, - Utils.EscapeCsv(val), - Utils.EscapeCsv(e.Currency), - e.Amount, - e.MultiplierSeconds, - e.MultiplierPoints, - e.ProcessedToSubathon, - e.GetFinalSecondsValueRaw(), - e.GetFinalPointsValue(), - e.EventTimestamp.ToString("yyyy-MM-dd HH:mm:ss"), - Utils.EscapeCsv(e.SecondaryValue), - Utils.EscapeCsv(e.EventTypeMeta), - Utils.EscapeCsv(commonMeta) - )); - } + return await CsvUtils.ExportAsync($"subathon-{subathon.Id}", + [ + [ + "Id", "Source", "Type", "Command", "User", "Seconds Value", "Points Value", "Value", "Currency", + "Amount", "Multiplier Seconds", "Multiplier Points", "Processed", "Final Seconds Added", + "Final Points Added", "Timestamp", "Secondary Value", "Event Meta Type", "Event Meta Common Type" + ] + ], + events, e => { + string val = e.Value; + string? commonMeta = string.IsNullOrWhiteSpace(e.EventTypeMeta) ? "" : e.EventTypeMeta; + switch (e.EventType) { + case SubathonEventType.TwitchGiftSub or SubathonEventType.TwitchSub: + val = e.Value switch { + "1000" => "T1", + "2000" => "T2", + "3000" => "T3", + _ => val + }; + commonMeta = val; + break; + case SubathonEventType.GoAffProOrder + when GoAffProOrderHelper.TryGetStore(e.EventTypeMeta, out GoAffProStore? store): + commonMeta = store.InternalName; + break; + } - await File.WriteAllTextAsync(filepath, sb.ToString(), Encoding.UTF8); - - try { - bool isTest = - AppDomain.CurrentDomain.GetAssemblies() - .Any(a => a.FullName!.StartsWith("xunit", StringComparison.OrdinalIgnoreCase)); - if (!isTest) - Process.Start(new ProcessStartInfo { - FileName = exportDir, - UseShellExecute = true, - Verb = "open" - }); - } - catch { - /**/ - } + return [ + e.Id, e.Source, e.EventType, e.Command, e.User, e.SecondsValue, e.PointsValue, val, e.Currency, + e.Amount, e.MultiplierSeconds, e.MultiplierPoints, e.ProcessedToSubathon, + e.GetFinalSecondsValueRaw(), e.GetFinalPointsValue(), e.EventTimestamp, e.SecondaryValue, + e.EventTypeMeta, commonMeta + ]; + }); } public static async Task PauseAllTimers(AppDbContext db) { @@ -466,6 +447,8 @@ public static void SeedDefaultValues(AppDbContext db) { new() { EventType = SubathonEventType.TangiaTokens, Seconds = 0.12 }, new() { EventType = SubathonEventType.PallyGGDonation, Seconds = 12 }, // per 1 USD, Pally is USD only new() { EventType = SubathonEventType.TiltifyDonation, Seconds = 12 }, + new() { EventType = SubathonEventType.PatreonPledge, Meta = "DEFAULT", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.PatreonPledge, Meta = Utils.PerUnitMeta, Seconds = 12 }, new() { EventType = SubathonEventType.TreatStreamOrder, Seconds = 600 }, // per treat, always 1 item new() { EventType = SubathonEventType.MakeShipPledge, Meta = "DEFAULT", Seconds = 60 @@ -530,7 +513,8 @@ private static void SeedKnownGoAffProStores(AppDbContext db) { new() { SiteId = 7111695, StoreName = "V1 Tech", EventName = "V1 Tech Order" }, new() { SiteId = 7120088, StoreName = "Plush Foundry", EventName = "PlushFoundry Order" }, new() { SiteId = 7112002, StoreName = "Horizons Merch", EventName = "Horizons Merch Order" }, - new() { SiteId = 7181690, StoreName = "Redtail Retail", EventName = "Redtail Order" } + new() { SiteId = 7181690, StoreName = "Redtail Retail", EventName = "Redtail Order" }, + new() { SiteId = 7158345, StoreName = "Xepher Arcade", EventName = "Xepher Arcade Order" }, }; foreach (GoAffProStore def in defaults) { @@ -558,8 +542,8 @@ private static void SeedKnownGoAffProStores(AppDbContext db) { } private static void MigrateLegacyGoAffProData(AppDbContext db) { - // Convert rows stored under retired per-store GoAffPro enum values to the single - // GoAffProOrder type, SiteId in the meta column + // Convert rows stored under legacy per-store GoAffPro enum values to the single + // GoAffProOrder type, SiteId in the meta column - only relevant if updating from very old var newGoAffProOrderInt = (int)SubathonEventType.GoAffProOrder; foreach (SubathonEventType legacyType in Enum.GetValues() @@ -603,39 +587,5 @@ private static void MigrateLegacyData(AppDbContext db) { s.SetProperty(x => x.ReversedTime, false)); MigrateLegacyGoAffProData(db); - - // var fontTypes = WidgetVariableTypeHelper.FontVariables.ToList(); - // var widgetsMissingFontTypes = db.Widgets - // .Where(w => !fontTypes.All(ft => w.JsVariables.Any(v => v.Type == ft))) - // .Include(w => w.JsVariables) - // .ToList(); - // if (widgetsMissingFontTypes.Count > 0) - // { - // var newVars = new List(); - // foreach (var widget in widgetsMissingFontTypes) - // { - // var existingTypes = widget.JsVariables - // .Select(v => v.Type) - // .ToHashSet(); - // - // foreach (var fontVar in fontTypes.Where(ft => !existingTypes.Contains(ft))) - // { - // newVars.Add(new JsVariable - // { - // WidgetId = widget.Id, - // Widget = widget, - // Type = fontVar, - // Name = $"{fontVar}s", - // Description = $"Custom font names to include from {fontVar}s, comma separated", - // Value = string.Empty - // }); - // } - // } - // if (newVars.Count > 0) - // { - // db.JsVariables.AddRange(newVars); - // db.SaveChanges(); - // } - // } } } \ No newline at end of file diff --git a/SubathonManager.Data/Migrations/20261002031525_updateActinsAndWheelStuff.Designer.cs b/SubathonManager.Data/Migrations/20261002031525_updateActinsAndWheelStuff.Designer.cs new file mode 100644 index 00000000..5af09ecf --- /dev/null +++ b/SubathonManager.Data/Migrations/20261002031525_updateActinsAndWheelStuff.Designer.cs @@ -0,0 +1,1143 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SubathonManager.Data; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261002031525_updateActinsAndWheelStuff")] + partial class updateActinsAndWheelStuff + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("CssVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.GoAffProStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("GoAffProStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("JsVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.Property("Valid") + .HasColumnType("INTEGER"); + + b.HasKey("ProductId"); + + b.HasIndex("StoreId"); + + b.ToTable("JuniperProducts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.ToTable("JuniperStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MakeShipTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Orders") + .HasColumnType("INTEGER"); + + b.Property("ProductType") + .HasColumnType("INTEGER"); + + b.Property("Sales") + .HasColumnType("INTEGER"); + + b.Property("ShopifyProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MakeShipTrackings"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ApplyToPoints") + .HasColumnType("INTEGER"); + + b.Property("ApplyToSeconds") + .HasColumnType("INTEGER"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("FromHypeTrain") + .HasColumnType("INTEGER"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Started") + .HasColumnType("TEXT"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SubathonId") + .IsUnique(); + + b.ToTable("MultiplierDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.ScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EndMinute") + .HasColumnType("INTEGER"); + + b.Property("IsDone") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("StartMinute") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("ScheduleItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.StateValue", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("StateValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CapDateTime") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsPaused") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsCumulative") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsElapsed") + .HasColumnType("INTEGER"); + + b.Property("MoneySum") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("ReversedTime") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Source") + .HasColumnType("INTEGER") + .HasColumnOrder(1); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("Command") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("CurrentPoints") + .HasColumnType("INTEGER"); + + b.Property("CurrentTime") + .HasColumnType("INTEGER"); + + b.Property("EventTimestamp") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("EventTypeMeta") + .HasColumnType("TEXT"); + + b.Property("MultiplierPoints") + .HasColumnType("REAL"); + + b.Property("MultiplierSeconds") + .HasColumnType("REAL"); + + b.Property("PointsValue") + .HasColumnType("INTEGER"); + + b.Property("ProcessedToSubathon") + .HasColumnType("INTEGER"); + + b.Property("SecondaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecondsValue") + .HasColumnType("REAL"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.Property("TertiaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("User") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasReversed") + .HasColumnType("INTEGER"); + + b.HasKey("Id", "Source"); + + b.HasIndex("SubathonId"); + + b.ToTable("SubathonEvents"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("GoalSetId") + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GoalSetId"); + + b.ToTable("SubathonGoals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonGoalSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CompletionDuration") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FilterEventType") + .HasColumnType("INTEGER"); + + b.Property("FilterMeta") + .HasColumnType("TEXT"); + + b.Property("FilterSubType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SubType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPrompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BaselineCount") + .HasColumnType("INTEGER"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("PromptId") + .HasColumnType("TEXT"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SnapshotTargetValue") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PromptId"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPromptRuns"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Cooldown") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RandomOffset") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonPromptSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonValue", b => + { + b.Property("EventType") + .HasColumnType("INTEGER") + .HasColumnOrder(0); + + b.Property("Meta") + .HasColumnType("TEXT") + .HasColumnOrder(1); + + b.Property("Points") + .HasColumnType("REAL"); + + b.Property("Seconds") + .HasColumnType("REAL"); + + b.HasKey("EventType", "Meta"); + + b.ToTable("SubathonValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Weight") + .HasColumnType("INTEGER"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.ToTable("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SpinCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("WheelSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ActionType") + .HasColumnType("INTEGER"); + + b.Property("Parameter") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelItemId") + .IsUnique(); + + b.ToTable("WheelSpinActions"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActionProgress") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.HasIndex("WheelItemId"); + + b.ToTable("WheelSpinHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CountThreshold") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MoneyThreshold") + .HasColumnType("REAL"); + + b.Property("SpinsToAdd") + .HasColumnType("INTEGER"); + + b.Property("TierValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WheelSpinTriggers"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("SpinsAdded") + .HasColumnType("INTEGER"); + + b.Property("SubathonEventId") + .HasColumnType("TEXT"); + + b.Property("SubathonEventType") + .HasColumnType("INTEGER"); + + b.Property("TriggerId") + .HasColumnType("TEXT"); + + b.Property("TriggerSource") + .HasColumnType("INTEGER"); + + b.Property("TriggerUser") + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TriggerId"); + + b.ToTable("WheelSpinTriggerHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("HtmlPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RouteId") + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RouteId"); + + b.ToTable("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WidgetCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Entry") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileModifiedTicks") + .HasColumnType("INTEGER"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Group") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewCachePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewImage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PackPath") + .IsUnique(); + + b.ToTable("WidgetCatalogEntries"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("CssVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("JsVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.HasOne("SubathonManager.Core.Models.JuniperStore", "Store") + .WithMany("Products") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithOne("Multiplier") + .HasForeignKey("SubathonManager.Core.Models.MultiplierData", "SubathonId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithMany() + .HasForeignKey("SubathonId"); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonGoalSet", "LinkedGoalSet") + .WithMany("Goals") + .HasForeignKey("GoalSetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedGoalSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany("Prompts") + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPrompt", "LinkedPrompt") + .WithMany() + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany() + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("LinkedPrompt"); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany("WheelItems") + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithOne("Action") + .HasForeignKey("SubathonManager.Core.Models.WheelSpinAction", "WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany() + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithMany() + .HasForeignKey("WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSpinTrigger", "Trigger") + .WithMany("History") + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.HasOne("SubathonManager.Core.Models.Route", "Route") + .WithMany("Widgets") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Navigation("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Navigation("Multiplier") + .IsRequired(); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Navigation("Goals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Navigation("Prompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Navigation("Action"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Navigation("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Navigation("CssVariables"); + + b.Navigation("JsVariables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SubathonManager.Data/Migrations/20261002031525_updateActinsAndWheelStuff.cs b/SubathonManager.Data/Migrations/20261002031525_updateActinsAndWheelStuff.cs new file mode 100644 index 00000000..2b4c3c1c --- /dev/null +++ b/SubathonManager.Data/Migrations/20261002031525_updateActinsAndWheelStuff.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + /// + public partial class updateActinsAndWheelStuff : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ActionProgress", + table: "WheelSpinHistories", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ActionProgress", + table: "WheelSpinHistories"); + } + } +} diff --git a/SubathonManager.Data/Migrations/20261003232544_addActionsGlobalsSecrets.Designer.cs b/SubathonManager.Data/Migrations/20261003232544_addActionsGlobalsSecrets.Designer.cs new file mode 100644 index 00000000..f5cc0240 --- /dev/null +++ b/SubathonManager.Data/Migrations/20261003232544_addActionsGlobalsSecrets.Designer.cs @@ -0,0 +1,1174 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SubathonManager.Data; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261003232544_addActionsGlobalsSecrets")] + partial class addActionsGlobalsSecrets + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("SubathonManager.Core.Models.ActionGlobal", b => + { + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Kind", "Name"); + + b.ToTable("ActionGlobals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("CssVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.GoAffProStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("GoAffProStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("JsVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.Property("Valid") + .HasColumnType("INTEGER"); + + b.HasKey("ProductId"); + + b.HasIndex("StoreId"); + + b.ToTable("JuniperProducts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.ToTable("JuniperStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MakeShipTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Orders") + .HasColumnType("INTEGER"); + + b.Property("ProductType") + .HasColumnType("INTEGER"); + + b.Property("Sales") + .HasColumnType("INTEGER"); + + b.Property("ShopifyProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MakeShipTrackings"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ApplyToPoints") + .HasColumnType("INTEGER"); + + b.Property("ApplyToSeconds") + .HasColumnType("INTEGER"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("FromHypeTrain") + .HasColumnType("INTEGER"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Started") + .HasColumnType("TEXT"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SubathonId") + .IsUnique(); + + b.ToTable("MultiplierDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.ScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EndMinute") + .HasColumnType("INTEGER"); + + b.Property("IsDone") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("StartMinute") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("ScheduleItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.StateValue", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("StateValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CapDateTime") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsPaused") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsCumulative") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsElapsed") + .HasColumnType("INTEGER"); + + b.Property("MoneySum") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("ReversedTime") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Source") + .HasColumnType("INTEGER") + .HasColumnOrder(1); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("Command") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("CurrentPoints") + .HasColumnType("INTEGER"); + + b.Property("CurrentTime") + .HasColumnType("INTEGER"); + + b.Property("EventTimestamp") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("EventTypeMeta") + .HasColumnType("TEXT"); + + b.Property("MultiplierPoints") + .HasColumnType("REAL"); + + b.Property("MultiplierSeconds") + .HasColumnType("REAL"); + + b.Property("PointsValue") + .HasColumnType("INTEGER"); + + b.Property("ProcessedToSubathon") + .HasColumnType("INTEGER"); + + b.Property("SecondaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecondsValue") + .HasColumnType("REAL"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.Property("TertiaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("User") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasReversed") + .HasColumnType("INTEGER"); + + b.HasKey("Id", "Source"); + + b.HasIndex("SubathonId"); + + b.ToTable("SubathonEvents"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("GoalSetId") + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GoalSetId"); + + b.ToTable("SubathonGoals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonGoalSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CompletionDuration") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FilterEventType") + .HasColumnType("INTEGER"); + + b.Property("FilterMeta") + .HasColumnType("TEXT"); + + b.Property("FilterSubType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SubType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPrompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BaselineCount") + .HasColumnType("INTEGER"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("PromptId") + .HasColumnType("TEXT"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SnapshotTargetValue") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PromptId"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPromptRuns"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Cooldown") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RandomOffset") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonPromptSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonValue", b => + { + b.Property("EventType") + .HasColumnType("INTEGER") + .HasColumnOrder(0); + + b.Property("Meta") + .HasColumnType("TEXT") + .HasColumnOrder(1); + + b.Property("Points") + .HasColumnType("REAL"); + + b.Property("Seconds") + .HasColumnType("REAL"); + + b.HasKey("EventType", "Meta"); + + b.ToTable("SubathonValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Weight") + .HasColumnType("INTEGER"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.ToTable("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SpinCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("WheelSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ActionType") + .HasColumnType("INTEGER"); + + b.Property("Parameter") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelItemId") + .IsUnique(); + + b.ToTable("WheelSpinActions"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActionProgress") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.HasIndex("WheelItemId"); + + b.ToTable("WheelSpinHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CountThreshold") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MoneyThreshold") + .HasColumnType("REAL"); + + b.Property("SpinsToAdd") + .HasColumnType("INTEGER"); + + b.Property("TierValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WheelSpinTriggers"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("SpinsAdded") + .HasColumnType("INTEGER"); + + b.Property("SubathonEventId") + .HasColumnType("TEXT"); + + b.Property("SubathonEventType") + .HasColumnType("INTEGER"); + + b.Property("TriggerId") + .HasColumnType("TEXT"); + + b.Property("TriggerSource") + .HasColumnType("INTEGER"); + + b.Property("TriggerUser") + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TriggerId"); + + b.ToTable("WheelSpinTriggerHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("HtmlPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RouteId") + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RouteId"); + + b.ToTable("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WidgetCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Entry") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileModifiedTicks") + .HasColumnType("INTEGER"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Group") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewCachePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewImage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PackPath") + .IsUnique(); + + b.ToTable("WidgetCatalogEntries"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("CssVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("JsVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.HasOne("SubathonManager.Core.Models.JuniperStore", "Store") + .WithMany("Products") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithOne("Multiplier") + .HasForeignKey("SubathonManager.Core.Models.MultiplierData", "SubathonId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithMany() + .HasForeignKey("SubathonId"); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonGoalSet", "LinkedGoalSet") + .WithMany("Goals") + .HasForeignKey("GoalSetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedGoalSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany("Prompts") + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPrompt", "LinkedPrompt") + .WithMany() + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany() + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("LinkedPrompt"); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany("WheelItems") + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithOne("Action") + .HasForeignKey("SubathonManager.Core.Models.WheelSpinAction", "WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany() + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithMany() + .HasForeignKey("WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSpinTrigger", "Trigger") + .WithMany("History") + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.HasOne("SubathonManager.Core.Models.Route", "Route") + .WithMany("Widgets") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Navigation("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Navigation("Multiplier") + .IsRequired(); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Navigation("Goals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Navigation("Prompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Navigation("Action"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Navigation("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Navigation("CssVariables"); + + b.Navigation("JsVariables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SubathonManager.Data/Migrations/20261003232544_addActionsGlobalsSecrets.cs b/SubathonManager.Data/Migrations/20261003232544_addActionsGlobalsSecrets.cs new file mode 100644 index 00000000..280ecbb3 --- /dev/null +++ b/SubathonManager.Data/Migrations/20261003232544_addActionsGlobalsSecrets.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + /// + public partial class addActionsGlobalsSecrets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ActionGlobals", + columns: table => new + { + Kind = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 64, nullable: false, collation: "NOCASE"), + ValueType = table.Column(type: "INTEGER", nullable: false), + Value = table.Column(type: "TEXT", nullable: true), + StorageKey = table.Column(type: "TEXT", maxLength: 128, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ActionGlobals", x => new { x.Kind, x.Name }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ActionGlobals"); + } + } +} diff --git a/SubathonManager.Data/Migrations/20261006182631_addGlobalsToWidgets.Designer.cs b/SubathonManager.Data/Migrations/20261006182631_addGlobalsToWidgets.Designer.cs new file mode 100644 index 00000000..45b7aa0d --- /dev/null +++ b/SubathonManager.Data/Migrations/20261006182631_addGlobalsToWidgets.Designer.cs @@ -0,0 +1,1179 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SubathonManager.Data; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261006182631_addGlobalsToWidgets")] + partial class addGlobalsToWidgets + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("SubathonManager.Core.Models.ActionGlobal", b => + { + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Kind", "Name"); + + b.ToTable("ActionGlobals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("CssVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.GoAffProStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("GoAffProStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("JsVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.Property("Valid") + .HasColumnType("INTEGER"); + + b.HasKey("ProductId"); + + b.HasIndex("StoreId"); + + b.ToTable("JuniperProducts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.ToTable("JuniperStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MakeShipTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Orders") + .HasColumnType("INTEGER"); + + b.Property("ProductType") + .HasColumnType("INTEGER"); + + b.Property("Sales") + .HasColumnType("INTEGER"); + + b.Property("ShopifyProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MakeShipTrackings"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ApplyToPoints") + .HasColumnType("INTEGER"); + + b.Property("ApplyToSeconds") + .HasColumnType("INTEGER"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("FromHypeTrain") + .HasColumnType("INTEGER"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Started") + .HasColumnType("TEXT"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SubathonId") + .IsUnique(); + + b.ToTable("MultiplierDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.ScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EndMinute") + .HasColumnType("INTEGER"); + + b.Property("IsDone") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("StartMinute") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("ScheduleItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.StateValue", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("StateValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CapDateTime") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsPaused") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsCumulative") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsElapsed") + .HasColumnType("INTEGER"); + + b.Property("MoneySum") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("ReversedTime") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Source") + .HasColumnType("INTEGER") + .HasColumnOrder(1); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("Command") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("CurrentPoints") + .HasColumnType("INTEGER"); + + b.Property("CurrentTime") + .HasColumnType("INTEGER"); + + b.Property("EventTimestamp") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("EventTypeMeta") + .HasColumnType("TEXT"); + + b.Property("MultiplierPoints") + .HasColumnType("REAL"); + + b.Property("MultiplierSeconds") + .HasColumnType("REAL"); + + b.Property("PointsValue") + .HasColumnType("INTEGER"); + + b.Property("ProcessedToSubathon") + .HasColumnType("INTEGER"); + + b.Property("SecondaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecondsValue") + .HasColumnType("REAL"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.Property("TertiaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("User") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasReversed") + .HasColumnType("INTEGER"); + + b.HasKey("Id", "Source"); + + b.HasIndex("SubathonId"); + + b.ToTable("SubathonEvents"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("GoalSetId") + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GoalSetId"); + + b.ToTable("SubathonGoals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonGoalSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CompletionDuration") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FilterEventType") + .HasColumnType("INTEGER"); + + b.Property("FilterMeta") + .HasColumnType("TEXT"); + + b.Property("FilterSubType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SubType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPrompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BaselineCount") + .HasColumnType("INTEGER"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("PromptId") + .HasColumnType("TEXT"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SnapshotTargetValue") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PromptId"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPromptRuns"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Cooldown") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RandomOffset") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonPromptSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonValue", b => + { + b.Property("EventType") + .HasColumnType("INTEGER") + .HasColumnOrder(0); + + b.Property("Meta") + .HasColumnType("TEXT") + .HasColumnOrder(1); + + b.Property("Points") + .HasColumnType("REAL"); + + b.Property("Seconds") + .HasColumnType("REAL"); + + b.HasKey("EventType", "Meta"); + + b.ToTable("SubathonValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Weight") + .HasColumnType("INTEGER"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.ToTable("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SpinCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("WheelSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ActionType") + .HasColumnType("INTEGER"); + + b.Property("Parameter") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelItemId") + .IsUnique(); + + b.ToTable("WheelSpinActions"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActionProgress") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.HasIndex("WheelItemId"); + + b.ToTable("WheelSpinHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CountThreshold") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MoneyThreshold") + .HasColumnType("REAL"); + + b.Property("SpinsToAdd") + .HasColumnType("INTEGER"); + + b.Property("TierValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WheelSpinTriggers"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("SpinsAdded") + .HasColumnType("INTEGER"); + + b.Property("SubathonEventId") + .HasColumnType("TEXT"); + + b.Property("SubathonEventType") + .HasColumnType("INTEGER"); + + b.Property("TriggerId") + .HasColumnType("TEXT"); + + b.Property("TriggerSource") + .HasColumnType("INTEGER"); + + b.Property("TriggerUser") + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TriggerId"); + + b.ToTable("WheelSpinTriggerHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .HasColumnType("TEXT"); + + b.Property("GlobalVars") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("HtmlPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RouteId") + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RouteId"); + + b.ToTable("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WidgetCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Entry") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileModifiedTicks") + .HasColumnType("INTEGER"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Group") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewCachePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewImage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PackPath") + .IsUnique(); + + b.ToTable("WidgetCatalogEntries"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("CssVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("JsVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.HasOne("SubathonManager.Core.Models.JuniperStore", "Store") + .WithMany("Products") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithOne("Multiplier") + .HasForeignKey("SubathonManager.Core.Models.MultiplierData", "SubathonId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithMany() + .HasForeignKey("SubathonId"); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonGoalSet", "LinkedGoalSet") + .WithMany("Goals") + .HasForeignKey("GoalSetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedGoalSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany("Prompts") + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPrompt", "LinkedPrompt") + .WithMany() + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany() + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("LinkedPrompt"); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany("WheelItems") + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithOne("Action") + .HasForeignKey("SubathonManager.Core.Models.WheelSpinAction", "WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany() + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithMany() + .HasForeignKey("WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSpinTrigger", "Trigger") + .WithMany("History") + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.HasOne("SubathonManager.Core.Models.Route", "Route") + .WithMany("Widgets") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Navigation("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Navigation("Multiplier") + .IsRequired(); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Navigation("Goals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Navigation("Prompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Navigation("Action"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Navigation("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Navigation("CssVariables"); + + b.Navigation("JsVariables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SubathonManager.Data/Migrations/20261006182631_addGlobalsToWidgets.cs b/SubathonManager.Data/Migrations/20261006182631_addGlobalsToWidgets.cs new file mode 100644 index 00000000..3485dda6 --- /dev/null +++ b/SubathonManager.Data/Migrations/20261006182631_addGlobalsToWidgets.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + /// + public partial class addGlobalsToWidgets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GlobalVars", + table: "Widgets", + type: "TEXT", + maxLength: 4096, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GlobalVars", + table: "Widgets"); + } + } +} diff --git a/SubathonManager.Data/Migrations/20261007032117_addActionsToPromptRuns.Designer.cs b/SubathonManager.Data/Migrations/20261007032117_addActionsToPromptRuns.Designer.cs new file mode 100644 index 00000000..72ea57c7 --- /dev/null +++ b/SubathonManager.Data/Migrations/20261007032117_addActionsToPromptRuns.Designer.cs @@ -0,0 +1,1191 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SubathonManager.Data; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261007032117_addActionsToPromptRuns")] + partial class addActionsToPromptRuns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("SubathonManager.Core.Models.ActionGlobal", b => + { + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Kind", "Name"); + + b.ToTable("ActionGlobals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("CssVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.GoAffProStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("GoAffProStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("JsVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.Property("Valid") + .HasColumnType("INTEGER"); + + b.HasKey("ProductId"); + + b.HasIndex("StoreId"); + + b.ToTable("JuniperProducts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.ToTable("JuniperStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MakeShipTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Orders") + .HasColumnType("INTEGER"); + + b.Property("ProductType") + .HasColumnType("INTEGER"); + + b.Property("Sales") + .HasColumnType("INTEGER"); + + b.Property("ShopifyProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MakeShipTrackings"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ApplyToPoints") + .HasColumnType("INTEGER"); + + b.Property("ApplyToSeconds") + .HasColumnType("INTEGER"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("FromHypeTrain") + .HasColumnType("INTEGER"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Started") + .HasColumnType("TEXT"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SubathonId") + .IsUnique(); + + b.ToTable("MultiplierDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.ScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EndMinute") + .HasColumnType("INTEGER"); + + b.Property("IsDone") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("StartMinute") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("ScheduleItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.StateValue", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("StateValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CapDateTime") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsPaused") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsCumulative") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsElapsed") + .HasColumnType("INTEGER"); + + b.Property("MoneySum") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("ReversedTime") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Source") + .HasColumnType("INTEGER") + .HasColumnOrder(1); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("Command") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("CurrentPoints") + .HasColumnType("INTEGER"); + + b.Property("CurrentTime") + .HasColumnType("INTEGER"); + + b.Property("EventTimestamp") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("EventTypeMeta") + .HasColumnType("TEXT"); + + b.Property("MultiplierPoints") + .HasColumnType("REAL"); + + b.Property("MultiplierSeconds") + .HasColumnType("REAL"); + + b.Property("PointsValue") + .HasColumnType("INTEGER"); + + b.Property("ProcessedToSubathon") + .HasColumnType("INTEGER"); + + b.Property("SecondaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecondsValue") + .HasColumnType("REAL"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.Property("TertiaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("User") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasReversed") + .HasColumnType("INTEGER"); + + b.HasKey("Id", "Source"); + + b.HasIndex("SubathonId"); + + b.ToTable("SubathonEvents"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("GoalSetId") + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GoalSetId"); + + b.ToTable("SubathonGoals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonGoalSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CompletionDuration") + .HasColumnType("INTEGER"); + + b.Property("CustomActionId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FilterEventType") + .HasColumnType("INTEGER"); + + b.Property("FilterMeta") + .HasColumnType("TEXT"); + + b.Property("FilterSubType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SubType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPrompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActionId") + .HasColumnType("TEXT"); + + b.Property("ActionProgress") + .HasColumnType("TEXT"); + + b.Property("ActionStatus") + .HasColumnType("INTEGER"); + + b.Property("BaselineCount") + .HasColumnType("INTEGER"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("PromptId") + .HasColumnType("TEXT"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SnapshotTargetValue") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PromptId"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPromptRuns"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Cooldown") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RandomOffset") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonPromptSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonValue", b => + { + b.Property("EventType") + .HasColumnType("INTEGER") + .HasColumnOrder(0); + + b.Property("Meta") + .HasColumnType("TEXT") + .HasColumnOrder(1); + + b.Property("Points") + .HasColumnType("REAL"); + + b.Property("Seconds") + .HasColumnType("REAL"); + + b.HasKey("EventType", "Meta"); + + b.ToTable("SubathonValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Weight") + .HasColumnType("INTEGER"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.ToTable("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SpinCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("WheelSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ActionType") + .HasColumnType("INTEGER"); + + b.Property("Parameter") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelItemId") + .IsUnique(); + + b.ToTable("WheelSpinActions"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActionProgress") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.HasIndex("WheelItemId"); + + b.ToTable("WheelSpinHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CountThreshold") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MoneyThreshold") + .HasColumnType("REAL"); + + b.Property("SpinsToAdd") + .HasColumnType("INTEGER"); + + b.Property("TierValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WheelSpinTriggers"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("SpinsAdded") + .HasColumnType("INTEGER"); + + b.Property("SubathonEventId") + .HasColumnType("TEXT"); + + b.Property("SubathonEventType") + .HasColumnType("INTEGER"); + + b.Property("TriggerId") + .HasColumnType("TEXT"); + + b.Property("TriggerSource") + .HasColumnType("INTEGER"); + + b.Property("TriggerUser") + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TriggerId"); + + b.ToTable("WheelSpinTriggerHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .HasColumnType("TEXT"); + + b.Property("GlobalVars") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("HtmlPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RouteId") + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RouteId"); + + b.ToTable("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WidgetCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Entry") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileModifiedTicks") + .HasColumnType("INTEGER"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Group") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewCachePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewImage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PackPath") + .IsUnique(); + + b.ToTable("WidgetCatalogEntries"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("CssVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("JsVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.HasOne("SubathonManager.Core.Models.JuniperStore", "Store") + .WithMany("Products") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithOne("Multiplier") + .HasForeignKey("SubathonManager.Core.Models.MultiplierData", "SubathonId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithMany() + .HasForeignKey("SubathonId"); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonGoalSet", "LinkedGoalSet") + .WithMany("Goals") + .HasForeignKey("GoalSetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedGoalSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany("Prompts") + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPrompt", "LinkedPrompt") + .WithMany() + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany() + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("LinkedPrompt"); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany("WheelItems") + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithOne("Action") + .HasForeignKey("SubathonManager.Core.Models.WheelSpinAction", "WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany() + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithMany() + .HasForeignKey("WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSpinTrigger", "Trigger") + .WithMany("History") + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.HasOne("SubathonManager.Core.Models.Route", "Route") + .WithMany("Widgets") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Navigation("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Navigation("Multiplier") + .IsRequired(); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Navigation("Goals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Navigation("Prompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Navigation("Action"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Navigation("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Navigation("CssVariables"); + + b.Navigation("JsVariables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SubathonManager.Data/Migrations/20261007032117_addActionsToPromptRuns.cs b/SubathonManager.Data/Migrations/20261007032117_addActionsToPromptRuns.cs new file mode 100644 index 00000000..7c3e7895 --- /dev/null +++ b/SubathonManager.Data/Migrations/20261007032117_addActionsToPromptRuns.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + /// + public partial class addActionsToPromptRuns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CustomActionId", + table: "SubathonPrompts", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ActionId", + table: "SubathonPromptRuns", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ActionProgress", + table: "SubathonPromptRuns", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ActionStatus", + table: "SubathonPromptRuns", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CustomActionId", + table: "SubathonPrompts"); + + migrationBuilder.DropColumn( + name: "ActionId", + table: "SubathonPromptRuns"); + + migrationBuilder.DropColumn( + name: "ActionProgress", + table: "SubathonPromptRuns"); + + migrationBuilder.DropColumn( + name: "ActionStatus", + table: "SubathonPromptRuns"); + } + } +} diff --git a/SubathonManager.Data/Migrations/20261008003253_addDoneColToGoals.Designer.cs b/SubathonManager.Data/Migrations/20261008003253_addDoneColToGoals.Designer.cs new file mode 100644 index 00000000..6e2725f5 --- /dev/null +++ b/SubathonManager.Data/Migrations/20261008003253_addDoneColToGoals.Designer.cs @@ -0,0 +1,1194 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SubathonManager.Data; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261008003253_addDoneColToGoals")] + partial class addDoneColToGoals + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("SubathonManager.Core.Models.ActionGlobal", b => + { + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Kind", "Name"); + + b.ToTable("ActionGlobals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("CssVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.GoAffProStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("GoAffProStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("JsVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.Property("Valid") + .HasColumnType("INTEGER"); + + b.HasKey("ProductId"); + + b.HasIndex("StoreId"); + + b.ToTable("JuniperProducts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.ToTable("JuniperStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MakeShipTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Orders") + .HasColumnType("INTEGER"); + + b.Property("ProductType") + .HasColumnType("INTEGER"); + + b.Property("Sales") + .HasColumnType("INTEGER"); + + b.Property("ShopifyProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MakeShipTrackings"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ApplyToPoints") + .HasColumnType("INTEGER"); + + b.Property("ApplyToSeconds") + .HasColumnType("INTEGER"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("FromHypeTrain") + .HasColumnType("INTEGER"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Started") + .HasColumnType("TEXT"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SubathonId") + .IsUnique(); + + b.ToTable("MultiplierDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.ScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EndMinute") + .HasColumnType("INTEGER"); + + b.Property("IsDone") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("StartMinute") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("ScheduleItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.StateValue", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("StateValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CapDateTime") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsPaused") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsCumulative") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsElapsed") + .HasColumnType("INTEGER"); + + b.Property("MoneySum") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("ReversedTime") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Source") + .HasColumnType("INTEGER") + .HasColumnOrder(1); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("Command") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("CurrentPoints") + .HasColumnType("INTEGER"); + + b.Property("CurrentTime") + .HasColumnType("INTEGER"); + + b.Property("EventTimestamp") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("EventTypeMeta") + .HasColumnType("TEXT"); + + b.Property("MultiplierPoints") + .HasColumnType("REAL"); + + b.Property("MultiplierSeconds") + .HasColumnType("REAL"); + + b.Property("PointsValue") + .HasColumnType("INTEGER"); + + b.Property("ProcessedToSubathon") + .HasColumnType("INTEGER"); + + b.Property("SecondaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecondsValue") + .HasColumnType("REAL"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.Property("TertiaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("User") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasReversed") + .HasColumnType("INTEGER"); + + b.HasKey("Id", "Source"); + + b.HasIndex("SubathonId"); + + b.ToTable("SubathonEvents"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("GoalSetId") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GoalSetId"); + + b.ToTable("SubathonGoals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonGoalSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CompletionDuration") + .HasColumnType("INTEGER"); + + b.Property("CustomActionId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FilterEventType") + .HasColumnType("INTEGER"); + + b.Property("FilterMeta") + .HasColumnType("TEXT"); + + b.Property("FilterSubType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SubType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPrompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActionId") + .HasColumnType("TEXT"); + + b.Property("ActionProgress") + .HasColumnType("TEXT"); + + b.Property("ActionStatus") + .HasColumnType("INTEGER"); + + b.Property("BaselineCount") + .HasColumnType("INTEGER"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("PromptId") + .HasColumnType("TEXT"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SnapshotTargetValue") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PromptId"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPromptRuns"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Cooldown") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RandomOffset") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonPromptSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonValue", b => + { + b.Property("EventType") + .HasColumnType("INTEGER") + .HasColumnOrder(0); + + b.Property("Meta") + .HasColumnType("TEXT") + .HasColumnOrder(1); + + b.Property("Points") + .HasColumnType("REAL"); + + b.Property("Seconds") + .HasColumnType("REAL"); + + b.HasKey("EventType", "Meta"); + + b.ToTable("SubathonValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Weight") + .HasColumnType("INTEGER"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.ToTable("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SpinCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("WheelSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ActionType") + .HasColumnType("INTEGER"); + + b.Property("Parameter") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelItemId") + .IsUnique(); + + b.ToTable("WheelSpinActions"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActionProgress") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.HasIndex("WheelItemId"); + + b.ToTable("WheelSpinHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CountThreshold") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MoneyThreshold") + .HasColumnType("REAL"); + + b.Property("SpinsToAdd") + .HasColumnType("INTEGER"); + + b.Property("TierValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WheelSpinTriggers"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("SpinsAdded") + .HasColumnType("INTEGER"); + + b.Property("SubathonEventId") + .HasColumnType("TEXT"); + + b.Property("SubathonEventType") + .HasColumnType("INTEGER"); + + b.Property("TriggerId") + .HasColumnType("TEXT"); + + b.Property("TriggerSource") + .HasColumnType("INTEGER"); + + b.Property("TriggerUser") + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TriggerId"); + + b.ToTable("WheelSpinTriggerHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .HasColumnType("TEXT"); + + b.Property("GlobalVars") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("HtmlPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RouteId") + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RouteId"); + + b.ToTable("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WidgetCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Entry") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileModifiedTicks") + .HasColumnType("INTEGER"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Group") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewCachePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewImage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PackPath") + .IsUnique(); + + b.ToTable("WidgetCatalogEntries"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("CssVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("JsVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.HasOne("SubathonManager.Core.Models.JuniperStore", "Store") + .WithMany("Products") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithOne("Multiplier") + .HasForeignKey("SubathonManager.Core.Models.MultiplierData", "SubathonId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithMany() + .HasForeignKey("SubathonId"); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonGoalSet", "LinkedGoalSet") + .WithMany("Goals") + .HasForeignKey("GoalSetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedGoalSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany("Prompts") + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPrompt", "LinkedPrompt") + .WithMany() + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany() + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("LinkedPrompt"); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany("WheelItems") + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithOne("Action") + .HasForeignKey("SubathonManager.Core.Models.WheelSpinAction", "WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany() + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithMany() + .HasForeignKey("WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSpinTrigger", "Trigger") + .WithMany("History") + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.HasOne("SubathonManager.Core.Models.Route", "Route") + .WithMany("Widgets") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Navigation("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Navigation("Multiplier") + .IsRequired(); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Navigation("Goals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Navigation("Prompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Navigation("Action"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Navigation("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Navigation("CssVariables"); + + b.Navigation("JsVariables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SubathonManager.Data/Migrations/20261008003253_addDoneColToGoals.cs b/SubathonManager.Data/Migrations/20261008003253_addDoneColToGoals.cs new file mode 100644 index 00000000..541ae9eb --- /dev/null +++ b/SubathonManager.Data/Migrations/20261008003253_addDoneColToGoals.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + /// + public partial class addDoneColToGoals : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsCompleted", + table: "SubathonGoals", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsCompleted", + table: "SubathonGoals"); + } + } +} diff --git a/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs b/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs index 638541d5..574d1690 100644 --- a/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs @@ -15,7 +15,38 @@ partial class AppDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("SubathonManager.Core.Models.ActionGlobal", b => + { + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Kind", "Name"); + + b.ToTable("ActionGlobals"); + }); modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => { @@ -441,6 +472,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("GoalSetId") .HasColumnType("TEXT"); + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + b.Property("Points") .HasColumnType("INTEGER"); @@ -487,6 +521,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CompletionDuration") .HasColumnType("INTEGER"); + b.Property("CustomActionId") + .HasColumnType("TEXT"); + b.Property("Enabled") .HasColumnType("INTEGER"); @@ -537,6 +574,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("ActionId") + .HasColumnType("TEXT"); + + b.Property("ActionProgress") + .HasColumnType("TEXT"); + + b.Property("ActionStatus") + .HasColumnType("INTEGER"); + b.Property("BaselineCount") .HasColumnType("INTEGER"); @@ -711,6 +757,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("ActionProgress") + .HasColumnType("TEXT"); + b.Property("CreatedAt") .HasColumnType("TEXT"); @@ -810,6 +859,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DocsUrl") .HasColumnType("TEXT"); + b.Property("GlobalVars") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + b.Property("Height") .HasColumnType("INTEGER"); diff --git a/SubathonManager.Data/OverlayPorter.cs b/SubathonManager.Data/OverlayPorter.cs index 7ceb6690..1a6c769a 100644 --- a/SubathonManager.Data/OverlayPorter.cs +++ b/SubathonManager.Data/OverlayPorter.cs @@ -10,6 +10,7 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; using SubathonManager.Data.Overlays; using SubathonManager.Data.Widgets; @@ -29,13 +30,20 @@ public static class OverlayPorter { Converters = { new JsonStringEnumConverter() } }; + private static readonly JsonSerializerOptions ReadOptions = new() { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + #region EXPORT public static async Task ExportRouteAsync(Route route, string outputPath, string exportName, HashSet? excludedZipEntries = null, string version = "1", string appVersion = "", - string author = "", List? tags = null) { + string author = "", List? tags = null, + IReadOnlyDictionary? globalTypes = null) { List widgets = route.Widgets.ToList(); ExportPlan plan = BuildExportPlan(widgets, excludedZipEntries); + plan.GlobalTypes = globalTypes; await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, false); @@ -202,6 +210,7 @@ private static JsonElement BuildManifest(Route route, List widgets, Expo scale = new { x = w.ScaleX, y = w.ScaleY }, visibility = w.Visibility, docsUrl = w.DocsUrl, + globals = WidgetMetaGlobals.From(w, plan.GlobalTypes), cssVariables = w.CssVariables.Select(v => new { name = v.Name, value = v.Value }), jsVariables = jsVars }; @@ -329,10 +338,17 @@ public static async Task ImportExtractedRouteAsync( var newCssVariables = new List(); var newJsVariables = new List(); var repointedWidgets = new List(); + var requiredGlobals = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (ManifestWidget mw in manifestWidgets) { JsonElement wEl = mw.Element; string htmlAbsPath = mw.HtmlAbsPath; + WidgetMetaGlobals? widgetGlobals = ReadGlobals(wEl); + if (widgetGlobals != null) { + foreach ((string name, ActionValueType type) in widgetGlobals.Vars) { + requiredGlobals.TryAdd(name, type); + } + } string widgetExtractFolder = mw.PackFolder != null ? mw.OverlayFolder @@ -359,7 +375,8 @@ public static async Task ImportExtractedRouteAsync( Id = Guid.NewGuid(), RouteId = route.Id, Type = widgetType, Visibility = wEl.GetProperty("visibility").GetBoolean(), - DocsUrl = wEl.TryGetProperty("docsUrl", out JsonElement du) ? du.GetString() : null + DocsUrl = wEl.TryGetProperty("docsUrl", out JsonElement du) ? du.GetString() : null, + GlobalVars = widgetGlobals?.ToWidgetValue() ?? string.Empty }; JsonElement pos = wEl.GetProperty("position"); @@ -430,6 +447,7 @@ public static async Task ImportExtractedRouteAsync( NewCssVariables = newCssVariables, NewJsVariables = newJsVariables, RepointedWidgets = repointedWidgets, + RequiredGlobals = requiredGlobals, RouteIsNew = routeIsNew, MergedRouteId = routeIsNew ? null : route!.Id, MergedRouteName = renameMerged ? routeName : null @@ -438,6 +456,16 @@ public static async Task ImportExtractedRouteAsync( private record ManifestWidget(JsonElement Element, string HtmlAbsPath, string OverlayFolder, string? PackFolder); + private static WidgetMetaGlobals? ReadGlobals(JsonElement widgetElem) { + if (!widgetElem.TryGetProperty("globals", out JsonElement el) || el.ValueKind != JsonValueKind.Object) return null; + try { + return el.Deserialize(ReadOptions); + } + catch (JsonException) { + return null; + } + } + private static ManifestWidget ResolveManifestWidget(JsonElement wEl, string extractDir) { string htmlZipRelPath = wEl.TryGetProperty("htmlPath", out JsonElement hp) ? hp.GetString() ?? "" : ""; @@ -624,6 +652,8 @@ public class ImportResult { public string? FailReason { get; init; } public List RepointedWidgets { get; init; } = []; + + public Dictionary RequiredGlobals { get; init; } = new(StringComparer.OrdinalIgnoreCase); public Guid? MergedRouteId { get; init; } public string? MergedRouteName { get; init; } @@ -645,6 +675,7 @@ private class ExportPlan { public Dictionary WidgetPacks { get; } = new(); public List<(string Src, string ZipEntry)> FileCopies { get; } = []; public Dictionary> VariableRewrites { get; } = new(); + public IReadOnlyDictionary? GlobalTypes { get; set; } } #endregion diff --git a/SubathonManager.Data/Overlays/OverlayPackInstaller.cs b/SubathonManager.Data/Overlays/OverlayPackInstaller.cs index 69de2b82..eda723af 100644 --- a/SubathonManager.Data/Overlays/OverlayPackInstaller.cs +++ b/SubathonManager.Data/Overlays/OverlayPackInstaller.cs @@ -1,5 +1,6 @@ using System.IO.Compression; using System.Text.Json; +using SubathonManager.Core; namespace SubathonManager.Data.Overlays; @@ -98,10 +99,8 @@ private static OverlayManifest Parse(JsonElement root, string smoPath) { } private static string Str(JsonElement el, string name) { - return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out JsonElement v) && - v.ValueKind == JsonValueKind.String - ? v.GetString() ?? string.Empty - : string.Empty; + // just for a default + return Utils.GetJsonString(el, name) ?? string.Empty; } private static List StrList(JsonElement el, string name) { diff --git a/SubathonManager.Data/WidgetEntityHelper.cs b/SubathonManager.Data/WidgetEntityHelper.cs index d1e48bdd..11445289 100644 --- a/SubathonManager.Data/WidgetEntityHelper.cs +++ b/SubathonManager.Data/WidgetEntityHelper.cs @@ -293,7 +293,7 @@ internal WidgetMeta ConvertHtmlMetaToJsonMeta(Dictionary data) { WidgetVariableType.Boolean => bool.TryParse(rawValue, out bool b) && b, WidgetVariableType.Int => int.TryParse(rawValue, out int i) ? i : 0, WidgetVariableType.Percent => int.TryParse(rawValue, out int i) ? i : 0, - WidgetVariableType.Float => float.TryParse(rawValue, out float d) ? d : 0, + WidgetVariableType.Float => (float)Utils.ParseAmount(rawValue), _ => rawValue }; } diff --git a/SubathonManager.Data/WidgetPorter.cs b/SubathonManager.Data/WidgetPorter.cs index 35313a0c..cece3b54 100644 --- a/SubathonManager.Data/WidgetPorter.cs +++ b/SubathonManager.Data/WidgetPorter.cs @@ -118,6 +118,8 @@ public class ExportPlan { public Dictionary OptionalRewrites { get; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary GlobalTypes { get; } = new(StringComparer.OrdinalIgnoreCase); + public bool IsSelected(string zipEntry) { return Entries.Any(e => e.ZipEntry.Equals(zipEntry, StringComparison.OrdinalIgnoreCase) && (e.Locked || e.DefaultSelected)); @@ -542,6 +544,8 @@ private static string BuildWidgetMetaJson(Widget widget, ExportPlan plan, SmwExp metaVar.Value = ToMetaValue(jsVar.Type, plan.ResolveVarValue(jsVar), metaVar); } + meta.Globals = WidgetMetaGlobals.From(widget, plan.GlobalTypes, meta.Globals); + return JsonSerializer.Serialize(meta, MetaOptions); } @@ -562,7 +566,7 @@ private static object ToMetaValue(WidgetVariableType type, string value, WidgetM return int.TryParse(value, out int i) ? i : 0; case WidgetVariableType.Float: - return float.TryParse(value, out float f) ? f : 0f; + return (float)Utils.ParseAmount(value); default: if (type.IsListType()) return items; diff --git a/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs b/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs index 3cffda48..48f72cc7 100644 --- a/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs +++ b/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs @@ -1,5 +1,6 @@ using System.IO.Compression; using System.Text.Json; +using SubathonManager.Core; namespace SubathonManager.Data.Widgets; @@ -107,17 +108,15 @@ private static WidgetCollectionManifest Parse(JsonElement root, string smwcPath) } private static string Str(JsonElement el, string name) { - return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out JsonElement v) && - v.ValueKind == JsonValueKind.String - ? v.GetString() ?? string.Empty - : string.Empty; + // to have a default + return Utils.GetJsonString(el, name) ?? string.Empty; } private static List StrList(JsonElement el, string name) { if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out JsonElement v) || v.ValueKind != JsonValueKind.Array) - return new List(); + return []; return v.EnumerateArray() .Where(item => item.ValueKind == JsonValueKind.String) diff --git a/SubathonManager.Data/Widgets/WidgetPackInstaller.cs b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs index 67f56f90..8931b353 100644 --- a/SubathonManager.Data/Widgets/WidgetPackInstaller.cs +++ b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs @@ -1,5 +1,6 @@ using System.IO.Compression; using System.Text.Json; +using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Models; @@ -237,17 +238,15 @@ private static WidgetPackManifest Parse(JsonElement root, string smwPath) { } private static string Str(JsonElement el, string name) { - return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out JsonElement v) && - v.ValueKind == JsonValueKind.String - ? v.GetString() ?? string.Empty - : string.Empty; + // to have a default + return Utils.GetJsonString(el, name) ?? string.Empty; } private static List StrList(JsonElement el, string name) { if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out JsonElement v) || v.ValueKind != JsonValueKind.Array) - return new List(); + return []; return v.EnumerateArray() .Where(item => item.ValueKind == JsonValueKind.String) diff --git a/SubathonManager.Integration/DevTunnelsService.cs b/SubathonManager.Integration/DevTunnelsService.cs index 98377a57..9f34d79d 100644 --- a/SubathonManager.Integration/DevTunnelsService.cs +++ b/SubathonManager.Integration/DevTunnelsService.cs @@ -6,6 +6,7 @@ using DevTunnels.Client.Ports; using DevTunnels.Client.Tunnels; using Microsoft.Extensions.Logging; +using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; @@ -133,10 +134,30 @@ public async Task LogoutAsync(CancellationToken ct = defau BroadcastTunnelStatus(false, null); return new DevTunnelLoginStatus { Status = "Logged out" }; } + + [ExcludeFromCodeCoverage] + public async Task RequireTunnelAsync(SubathonEventSource source, + CancellationToken ct = default) { + IntegrationConnection tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); + if (tunnelConn.Status) return tunnelConn; + + await StartTunnelAsync(ct); + tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); + if (tunnelConn.Status) return tunnelConn; + + string reason = !IsCliInstalled ? "the DevTunnels CLI isn't installed" + : !IsLoggedIn ? "DevTunnels isn't logged in" + : "the tunnel failed to start"; + logger?.LogWarning("[{Source}] Can't connect: {Reason}", source, reason); + ErrorMessageEvents.RaiseErrorEvent("WARN", source.ToString(), + $"{source} needs a DevTunnel but {reason}. Check the DevTunnels settings.", DateTime.Now); + return null; + } public async Task StartTunnelAsync(CancellationToken ct = default) { - // Fast path: no lock needed if a session is already running. - if (_session != null) return; + // Fast path: no lock needed if the tunnel is already up. A session that is still + // starting must fall through to the lock so callers wait for it to be ready. + if (_session != null && IsTunnelRunning) return; await _startLock.WaitAsync(ct); try { diff --git a/SubathonManager.Integration/FourthWallService.cs b/SubathonManager.Integration/FourthWallService.cs index 122a3a55..c46bc9ba 100644 --- a/SubathonManager.Integration/FourthWallService.cs +++ b/SubathonManager.Integration/FourthWallService.cs @@ -36,7 +36,7 @@ public class FourthWallService( IConfig config, DevTunnelsService devTunnels, OAuthService oAuth) - : IWebhookIntegration { + : IWebhookIntegration, IMissedEventSource { private readonly string _configSection = "FourthWall"; private readonly FourthwallWebhookHandler _handler = new(new FourthwallWebhookSignatureVerifier()); @@ -45,6 +45,8 @@ public class FourthWallService( StorageKeys.FourthWallRefreshToken); public readonly Dictionary MembershipNames = new(); + internal int MaxMissedPages = 50; + internal int MissedPageSize = 100; private string? AccessToken => oAuth.GetAccessToken(OAuthKeys); private string? ShopName { get; set; } @@ -55,14 +57,14 @@ public async Task StartAsync(CancellationToken ct = default) { IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; bool enabled = HasTokenFile(); + BroadcastStatus(enabled, null); if (enabled) { logger?.LogInformation("[FourthWall] Webhook listener ready at {Path}", WebhookPath); - _ = devTunnels.StartTunnelAsync(ct); + _ = Task.Run(() => Initialize(ct), ct); } else { logger?.LogInformation("[FourthWall] Has not been setup before. Integration is disabled"); - BroadcastStatus(false, null); } await Task.CompletedTask; @@ -151,6 +153,13 @@ public async Task Initialize(CancellationToken ct = default) { try { await InitializeCoreAsync(ct); } + catch (Exception ex) { + logger?.LogError(ex, "[FourthWall] Failed to initialize"); + ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventSource.FourthWall), + "FourthWall failed to connect. Check the logs or try reconnecting in the FourthWall settings.", + DateTime.Now); + BroadcastStatus(HasTokenFile(), null); + } finally { _initLock.Release(); } @@ -158,20 +167,9 @@ public async Task Initialize(CancellationToken ct = default) { [ExcludeFromCodeCoverage] private async Task InitializeCoreAsync(CancellationToken ct) { - IntegrationConnection tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); - if (!tunnelConn.Status) { - await devTunnels.StartTunnelAsync(ct); - tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); - if (!tunnelConn.Status) { - string reason = !devTunnels.IsCliInstalled ? "the DevTunnels CLI isn't installed" - : !devTunnels.IsLoggedIn ? "DevTunnels isn't logged in" - : "the tunnel failed to start"; - logger?.LogWarning("[FourthWall] Can't connect: {Reason}", reason); - ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventSource.FourthWall), - $"FourthWall needs a DevTunnel but {reason}. Check the DevTunnels settings.", DateTime.Now); - BroadcastStatus(HasTokenFile(), null); - return; - } + if (await devTunnels.RequireTunnelAsync(SubathonEventSource.FourthWall, ct) is not { } tunnelConn) { + BroadcastStatus(HasTokenFile(), null); + return; } bool canConnect = await CheckForTokenAsync(ct); @@ -266,7 +264,7 @@ await client.OpenApi.V10.Webhooks[webhookConfigurationV1.Id].PutAsync( MembershipNames[x.Id] = x.Name; }); - IntegrationEvents.RaiseFourthWallMembershipsSynced(MembershipNames); + IntegrationEvents.RaiseMembershipTiersSynced(SubathonEventSource.FourthWall, MembershipNames.Values.ToList()); // Seed status; include public URL if the tunnel is already running tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); @@ -315,8 +313,6 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { public SubathonEvent? MapToSubathonEvent(FourthwallWebhookEvent fwEvent) { try { - OrderTypeModes sourceMode = config.GetOrderTypeMode(_configSection, - $"{SubathonEventType.FourthWallOrder}", OrderTypeModes.Dollar); OrderTypeModes sourceMode2 = config.GetOrderTypeMode(_configSection, $"{SubathonEventType.FourthWallGiftOrder}", OrderTypeModes.Dollar); var defaultCurrency = "USD"; @@ -324,87 +320,10 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { if (fwEvent.TestMode) username = "FourthWall Test"; //"SYSTEM"; SubathonEvent? ev = null; if (fwEvent is FourthwallDonationWebhookEvent donationEvent) { - DonationV1 d = donationEvent.Data; - if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(d.Username)) - username = d.Username.Split(' ').First(); - ev = new SubathonEvent { - Id = Utils.TryParseGuid(d.Id), - Source = string.Equals(username, "SYSTEM") - ? SubathonEventSource.Simulated - : SubathonEventSource.FourthWall, - EventType = SubathonEventType.FourthWallDonation, - User = username, - Value = - d.Amounts?.Total?.Value?.ToString("F2", CultureInfo.InvariantCulture) ?? - "0.00", - Currency = !string.IsNullOrWhiteSpace(d.Amounts?.Total?.Currency) - ? d.Amounts?.Total?.Currency - : defaultCurrency, - EventTimestamp = d.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime() - }; + ev = MapDonation(donationEvent.Data, fwEvent.TestMode); } else if (fwEvent is FourthwallOrderPlacedWebhookEvent orderPlacedEvent) { - OrderV1 order = orderPlacedEvent.Data; - if (!string.Equals("ORDER", order.Source?.Order?.Type ?? "", StringComparison.CurrentCultureIgnoreCase) - && !string.Equals("SAMPLES_ORDER", order.Source?.Order?.Type ?? "", - StringComparison.CurrentCultureIgnoreCase)) - return null; - - //if (order.Status != OrderV1_status.CONFIRMED) return null; - if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(order.Username)) - username = order.Username.Split(' ').First(); - if (string.Equals("SAMPLES_ORDER", order.Source?.Order?.Type ?? "", - StringComparison.CurrentCultureIgnoreCase)) - username = "Internal Samples"; - var itemCount = 0; - double totalValue = 0; - double totalDirect = 0; - string currency = !string.IsNullOrWhiteSpace(order.Amounts?.Subtotal?.Currency) - ? order.Amounts.Subtotal.Currency - : defaultCurrency; - - double costs = 0; - double prices = 0; - order.Offers?.ForEach(x => { - itemCount += x.Variant?.Quantity ?? 1; - costs += x.Variant?.Cost?.Value ?? 0; - prices += x.Variant?.Price?.Value ?? 0; - }); - - totalValue += order.Amounts?.Subtotal?.Value ?? 0; - totalDirect += order.Amounts?.Donation?.Value ?? 0; - - double profit = Math.Max(prices - costs, 0); - if (string.Equals("SAMPLES_ORDER", order.Source?.Order?.Type ?? "", - StringComparison.CurrentCultureIgnoreCase)) { - logger?.LogInformation("[FourthWall] Samples order placed. Setting profit from {Profit} to 0.", - profit); - profit = 0; - } - - totalDirect += profit; - ev = new SubathonEvent { - Id = Utils.TryParseGuid(order.Id), - Source = string.Equals(username, "SYSTEM") - ? SubathonEventSource.Simulated - : SubathonEventSource.FourthWall, - EventType = SubathonEventType.FourthWallOrder, - User = username, - Value = sourceMode switch { - OrderTypeModes.Item => $"{itemCount}", - OrderTypeModes.Order => "New", - _ => totalValue.ToString("F2", CultureInfo.InvariantCulture) - }, - Currency = sourceMode switch { - OrderTypeModes.Item => "items", - OrderTypeModes.Order => "order", - _ => !string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency - }, - Amount = Math.Max(itemCount, 1), - SecondaryValue = $"{totalDirect.ToString("F2", CultureInfo.InvariantCulture)}|{ - (!string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency)}", - EventTimestamp = order.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime() - }; + ev = MapOrder(orderPlacedEvent.Data, fwEvent.TestMode); } else if (fwEvent is FourthwallGiftPurchaseWebhookEvent giftPurchaseWebhookEvent) { GiftPurchaseV1 order = giftPurchaseWebhookEvent.Data; @@ -507,4 +426,153 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { return null; } } -} \ No newline at end of file + + private static SubathonEvent MapDonation(DonationV1 d, bool testMode) { + var username = testMode ? "FourthWall Test" : "FourthWall Customer"; + if (!testMode && !string.IsNullOrWhiteSpace(d.Username)) + username = d.Username.Split(' ').First(); + return new SubathonEvent { + Id = Utils.TryParseGuid(d.Id), + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, + EventType = SubathonEventType.FourthWallDonation, + User = username, + Value = d.Amounts?.Total?.Value?.ToString("F2", CultureInfo.InvariantCulture) ?? "0.00", + Currency = !string.IsNullOrWhiteSpace(d.Amounts?.Total?.Currency) ? d.Amounts?.Total?.Currency : "USD", + EventTimestamp = d.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime() + }; + } + + private static string GetOrderType(OrderV1 order) { + OrderV1.OrderV1_source? source = order.Source; + return source?.Order?.Type ?? source?.SamplesOrder?.Type ?? source?.TwitchGiftRedemption?.Type ?? + source?.GiveawayLinks?.Type ?? ""; + } + + private SubathonEvent? MapOrder(OrderV1 order, bool testMode) { + string orderType = GetOrderType(order); + bool isSamples = string.Equals("SAMPLES_ORDER", orderType, StringComparison.CurrentCultureIgnoreCase); + if (!isSamples && !string.Equals("ORDER", orderType, StringComparison.CurrentCultureIgnoreCase)) + return null; + + OrderTypeModes sourceMode = config.GetOrderTypeMode(_configSection, + $"{SubathonEventType.FourthWallOrder}", OrderTypeModes.Dollar); + const string defaultCurrency = "USD"; + var username = testMode ? "FourthWall Test" : "FourthWall Customer"; + if (!testMode && !string.IsNullOrWhiteSpace(order.Username)) + username = order.Username.Split(' ').First(); + if (isSamples) username = "Internal Samples"; + + var itemCount = 0; + double costs = 0; + double prices = 0; + order.Offers?.ForEach(x => { + itemCount += x.Variant?.Quantity ?? 1; + costs += x.Variant?.Cost?.Value ?? 0; + prices += x.Variant?.Price?.Value ?? 0; + }); + + double totalValue = order.Amounts?.Subtotal?.Value ?? 0; + double totalDirect = order.Amounts?.Donation?.Value ?? 0; + string currency = !string.IsNullOrWhiteSpace(order.Amounts?.Subtotal?.Currency) + ? order.Amounts.Subtotal.Currency + : defaultCurrency; + + double profit = Math.Max(prices - costs, 0); + if (isSamples) { + if (logger?.IsEnabled(LogLevel.Information) ?? false) + logger?.LogInformation("[FourthWall] Samples order placed. Setting profit from {Profit} to 0.", profit); + profit = 0; + } + + totalDirect += profit; + return new SubathonEvent { + Id = Utils.TryParseGuid(order.Id), + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, + EventType = SubathonEventType.FourthWallOrder, + User = username, + Value = sourceMode switch { + OrderTypeModes.Item => $"{itemCount}", + OrderTypeModes.Order => "New", + _ => totalValue.ToString("F2", CultureInfo.InvariantCulture) + }, + Currency = sourceMode switch { + OrderTypeModes.Item => "items", + OrderTypeModes.Order => "order", + _ => currency + }, + Amount = Math.Max(itemCount, 1), + SecondaryValue = $"{totalDirect.ToString("F2", CultureInfo.InvariantCulture)}|{currency}", + EventTimestamp = order.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime() + }; + } + + [ExcludeFromCodeCoverage] + public async Task> FetchMissedEventsAsync(DateTime from, DateTime to, + CancellationToken ct = default) { + if (!HasTokenFile()) throw new InvalidOperationException("FourthWall is not connected"); + if (oAuth.NeedsRefresh(OAuthKeys) && !await oAuth.RefreshAsync(OAuthKeys, ct)) + throw new InvalidOperationException("FourthWall login has expired, reconnect it first"); + + var client = new FourthwallApiClient( + new HttpClientRequestAdapter(new FourthwallBearerAuthenticationProvider(AccessToken!))); + DateTimeOffset start = from.ToUniversalTime(); + DateTimeOffset end = to.ToUniversalTime(); + List events = []; + + List orders = await FetchAllPagesAsync(async page => + (await client.OpenApi.V10.Order.GetAsync(r => { + r.QueryParameters.CreatedAtgt = start; + r.QueryParameters.CreatedAtlt = end; + r.QueryParameters.Page = page; + r.QueryParameters.Size = MissedPageSize; + }, ct))?.Results, o => o.Id); + List inRange = orders.Where(o => o.CreatedAt >= start && o.CreatedAt <= end).ToList(); + int cancelled = inRange.Count(o => o.Status == OrderV1_status.CANCELLED); + events.AddRange(inRange.Where(o => o.Status != OrderV1_status.CANCELLED) + .Select(o => MapOrder(o, false)).OfType()); + int mappedOrders = events.Count; + string skippedTypes = string.Join(", ", inRange.Where(o => o.Status != OrderV1_status.CANCELLED) + .Select(GetOrderType).Where(t => t is not "ORDER" and not "SAMPLES_ORDER") + .GroupBy(t => t == "" ? "(no source)" : t).Select(g => $"{g.Key} x{g.Count()}")); + + List donations = await FetchAllPagesAsync(async page => + (await client.OpenApi.V10.Donations.GetAsync(r => { + r.QueryParameters.Page = page; + r.QueryParameters.Size = MissedPageSize; + }, ct))?.Results, d => d.Id); + List donationsInRange = donations.Where(d => d.CreatedAt >= start && d.CreatedAt <= end).ToList(); + events.AddRange(donationsInRange.Where(d => d.Status is null or DonationV1_status.COMPLETED) + .Select(d => MapDonation(d, false))); + + if (logger?.IsEnabled(LogLevel.Information) ?? false) + logger?.LogInformation( + "[FourthWall] Missed event lookup {Start:u} to {End:u}: {Orders} order(s) returned, {InRange} in range, " + + "{Cancelled} cancelled, {Mapped} usable, skipped types [{Skipped}]; {Donations} donation(s) checked, " + + "{DonationsInRange} in range", + start, end, orders.Count, inRange.Count, cancelled, mappedOrders, skippedTypes, donations.Count, + donationsInRange.Count); + return events; + } + + private async Task> FetchAllPagesAsync(Func?>> getPage, Func getId) { + List all = []; + HashSet seen = []; + for (var page = 0; page < MaxMissedPages; page++) { + List items = await getPage(page) ?? []; + if (items.Count == 0) { + if (page == 0) continue; + break; + } + + List fresh = items.Where(i => seen.Add(getId(i) ?? Guid.NewGuid().ToString())).ToList(); + if (fresh.Count == 0) break; + all.AddRange(fresh); + } + + return all; + } +} diff --git a/SubathonManager.Integration/GoAffProService.cs b/SubathonManager.Integration/GoAffProService.cs index d8618b86..6f2d1e43 100644 --- a/SubathonManager.Integration/GoAffProService.cs +++ b/SubathonManager.Integration/GoAffProService.cs @@ -22,7 +22,7 @@ public class GoAffProService( ILogger? logger, IConfig config, ISecureStorage secureStorage, - ITimerService? timerService = null) : IDisposable, IAppService { + ITimerService? timerService = null) : IDisposable, IAppService, IMissedEventSource { private readonly string _configSection = "GoAffPro"; private GoAffProClient? _client; @@ -209,7 +209,7 @@ private async Task PollWithBackfillAsync(GoAffProClient client, TimeSpan initial } private async Task> FetchOrdersAsync(GoAffProClient client, DateTimeOffset? createdAtMin, - string? sinceId, CancellationToken ct) { + string? sinceId, CancellationToken ct, DateTimeOffset? createdAtMax = null) { List all = new(); var offset = 0; @@ -220,6 +220,7 @@ private async Task> FetchOrdersAsync(GoAffProClient clie reqConfig.QueryParameters.Limit = PageSize; reqConfig.QueryParameters.Offset = pageOffset; if (createdAtMin.HasValue) reqConfig.QueryParameters.CreatedAtMin = createdAtMin.Value; + if (createdAtMax.HasValue) reqConfig.QueryParameters.CreatedAtMax = createdAtMax.Value; if (!string.IsNullOrWhiteSpace(sinceId)) reqConfig.QueryParameters.SinceId = sinceId; }, ct); @@ -320,14 +321,28 @@ public void SimulateOrder(decimal total, int itemCount, decimal commissionTotal, HandleOrder(order); } + [ExcludeFromCodeCoverage] + public async Task> FetchMissedEventsAsync(DateTime from, DateTime to, + CancellationToken ct = default) { + GoAffProClient client = _client ?? throw new InvalidOperationException("GoAffPro is not connected"); + List orders = await FetchOrdersAsync(client, from.ToUniversalTime(), null, ct, + to.ToUniversalTime()); + return orders.Select(MapOrder).OfType().ToList(); + } + private void HandleOrder(UserOrderFeedItem order) { + SubathonEvent? ev = MapOrder(order); + if (ev != null) SubathonEvents.RaiseSubathonEventCreated(ev); + } + + private SubathonEvent? MapOrder(UserOrderFeedItem order) { try { // If an order comes in as new and then approved, only one is added due to unique id's if (order.Id == null || order.SiteId == null || order.LineItems == null || string.IsNullOrWhiteSpace(order.Status) || (!string.Equals(order.Status, "approved", StringComparison.OrdinalIgnoreCase) && - !string.Equals(order.Status, "new", StringComparison.OrdinalIgnoreCase))) return; + !string.Equals(order.Status, "new", StringComparison.OrdinalIgnoreCase))) return null; // new and approved can both come in, but same id will mean it doesn't add twice var ev = new SubathonEvent { @@ -346,12 +361,12 @@ private void HandleOrder(UserOrderFeedItem order) { } int? site = order.SiteId!.Integer; - if (site == null || !GoAffProStoreRegistry.TryGetBySiteId((int)site, out GoAffProStore? store)) return; - if (!store.Enabled) return; + if (site == null || !GoAffProStoreRegistry.TryGetBySiteId((int)site, out GoAffProStore? store)) return null; + if (!store.Enabled) return null; // we will listen for these sites regardless in orders, but will ignore if not enabled. bool enabled = config.GetBool(_configSection, $"{store.InternalName}.Enabled", true); - if (!enabled) return; + if (!enabled) return null; OrderTypeModes sourceMode = config.GetOrderTypeMode(_configSection, store.InternalName, OrderTypeModes.Dollar); @@ -368,18 +383,11 @@ private void HandleOrder(UserOrderFeedItem order) { } ev.Amount = itemCount; - switch (sourceMode) { - case OrderTypeModes.Dollar: - ev.Value = $"{order.Subtotal}"; - break; - case OrderTypeModes.Order: - ev.Value = "New"; - break; - default: { - ev.Value = $"{itemCount}"; - break; - } - } + ev.Value = sourceMode switch { + OrderTypeModes.Dollar => $"{order.Subtotal}", + OrderTypeModes.Order => "New", + _ => $"{itemCount}" + }; ev.SecondaryValue = $"{order.Commission}|{order.Currency}"; ev.EventType = SubathonEventType.GoAffProOrder; @@ -389,11 +397,12 @@ private void HandleOrder(UserOrderFeedItem order) { if (ev.Source == SubathonEventSource.Simulated) ev.User = $"SYSTEM {store.InternalName}"; - SubathonEvents.RaiseSubathonEventCreated(ev); + return ev; } catch (Exception e) { logger?.LogWarning(e, "[GoAffPro] Failed to consume order. Data: {Serialize}", JsonSerializer.Serialize(order.AdditionalData)); + return null; } } diff --git a/SubathonManager.Integration/MakeShipService.cs b/SubathonManager.Integration/MakeShipService.cs index 4c76b532..dff67dca 100644 --- a/SubathonManager.Integration/MakeShipService.cs +++ b/SubathonManager.Integration/MakeShipService.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; @@ -24,13 +25,16 @@ public partial class MakeShipService( private const string TimerKey = "makeship-poll"; private const int InvalidAfterConsecutiveFailures = 3; - private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30); + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60); + private static readonly TimeSpan BlockedPollInterval = TimeSpan.FromMinutes(10); private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(20); private static readonly TimeSpan StaggerDelay = TimeSpan.FromSeconds(2); private readonly Dictionary _contentFailures = new(); private readonly HashSet _syncedSinceStart = new(); private readonly Lock _syncLock = new(); private bool _lastBroadcastStatus; + private string _lastBroadcastName = ""; + private bool _backedOff; private int _pollSweepRunning; private bool _running; @@ -61,6 +65,7 @@ public Task StartAsync(CancellationToken ct = default) { } _running = true; + _backedOff = false; timerService?.Register(TimerKey, PollInterval, PollTimerTick); logger?.LogInformation("[MakeShip] Tracking started ({Count} urls)", trackings.Count); @@ -138,9 +143,22 @@ private async Task PollAllAsync(CancellationToken ct) { List trackings = MakeShipTrackingRegistry.All(); Connected = trackings.Count > 0 && trackings.Any(IsTracked); + UpdatePollInterval(trackings.Any(t => t.Blocked)); BroadcastStatus(); } + private void UpdatePollInterval(bool blocked) { + if (!_running || blocked == _backedOff) return; + _backedOff = blocked; + TimeSpan interval = blocked ? BlockedPollInterval : PollInterval; + timerService?.Register(TimerKey, interval, PollTimerTick); + if (blocked) + logger?.LogWarning("[MakeShip] Requests are being blocked (403). Polling every {Interval} until it clears", + interval); + else + logger?.LogInformation("[MakeShip] No longer blocked. Polling every {Interval}", interval); + } + private static bool IsTracked(MakeShipTracking t) { return t.ProductType is MakeShipProductType.Petition or MakeShipProductType.Campaign && !string.IsNullOrEmpty(t.ShopifyProductId); @@ -148,6 +166,7 @@ private static bool IsTracked(MakeShipTracking t) { internal async Task PollTrackingAsync(MakeShipTracking tracking, CancellationToken ct) { if (tracking.ProductType == MakeShipProductType.Invalid) return; + tracking.Blocked = false; if (string.IsNullOrEmpty(tracking.ShopifyProductId) && !await ResolveTrackingAsync(tracking, ct)) { MakeShipTrackingRegistry.RaiseTrackingUpdated(tracking); return; @@ -216,7 +235,7 @@ private async Task ResolveTrackingAsync(MakeShipTracking tracking, Cancell return false; } - string? html = await GetStringAsync(tracking.Url, ct); + string? html = await GetStringAsync(tracking, tracking.Url, ct); if (html == null) return false; string? productId = type == MakeShipProductType.Petition @@ -241,7 +260,7 @@ private async Task ResolveTrackingAsync(MakeShipTracking tracking, Cancell } private async Task RefreshPetitionAsync(MakeShipTracking tracking, CancellationToken ct) { - string? json = await GetStringAsync( + string? json = await GetStringAsync(tracking, $"{PreProductApiBase}{tracking.ShopifyProductId}.json{PreProductShopQuery}", ct); if (json == null) return false; @@ -265,7 +284,7 @@ private async Task RefreshPetitionAsync(MakeShipTracking tracking, Cancell } private async Task RefreshCampaignAsync(MakeShipTracking tracking, CancellationToken ct) { - string? json = await GetStringAsync( + string? json = await GetStringAsync(tracking, $"{StorefrontBase}/products/{tracking.ShopifyProductId}/sales-quantity", ct); if (json == null) { logger?.LogWarning("[MakeShip] No sales-quantity for product {Id} ({Url})", @@ -288,7 +307,7 @@ private async Task RefreshCampaignAsync(MakeShipTracking tracking, Cancell } private async Task RefreshPledgeCountAsync(MakeShipTracking tracking, string url, CancellationToken ct) { - string? body = await GetStringAsync(url, ct); + string? body = await GetStringAsync(tracking, url, ct); if (body == null) { logger?.LogWarning("[MakeShip] Pledge count request failed for '{Name}' ({Url})", tracking.Name, url); return false; @@ -368,19 +387,26 @@ public static void Simulate(string name, bool isPetition, int count) { private void BroadcastStatus(bool force = false) { List trackings = MakeShipTrackingRegistry.All(); int tracked = trackings.Count(IsTracked); - bool status = _running && tracked > 0; - if (!force && status == _lastBroadcastStatus) return; + int blocked = trackings.Count(t => t.Blocked); + bool status = _running && tracked > 0 && blocked == 0; + string name = trackings.Count == 0 ? "" + : blocked > 0 ? $"{tracked}/{trackings.Count} tracked, {blocked} errors" + : $"{tracked}/{trackings.Count} tracked"; + + if (!force && status == _lastBroadcastStatus && name == _lastBroadcastName) return; _lastBroadcastStatus = status; + _lastBroadcastName = name; + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.MakeShip, Service = nameof(SubathonEventSource.MakeShip), - Name = trackings.Count == 0 ? "" : $"{tracked}/{trackings.Count} tracked", + Name = name, Status = status, Configured = trackings.Count > 0 }); } - private async Task GetStringAsync(string url, CancellationToken ct) { + private async Task GetStringAsync(MakeShipTracking tracking, string url, CancellationToken ct) { using HttpClient client = httpClientFactory.CreateClient(nameof(MakeShipService)); client.Timeout = HttpTimeout; if (!client.DefaultRequestHeaders.UserAgent.Any()) @@ -388,6 +414,21 @@ private void BroadcastStatus(bool force = false) { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) SubathonManager"); try { HttpResponseMessage response = await client.GetAsync(url, ct); + if (response.StatusCode == HttpStatusCode.Forbidden) { + tracking.Blocked = true; + if (logger != null && logger.IsEnabled(LogLevel.Debug)) { + string headers = string.Join("; ", response.Headers.Concat(response.Content.Headers) + .Select(h => $"{h.Key}: {string.Join(",", h.Value)}")); + string body = await response.Content.ReadAsStringAsync(ct); + logger?.LogDebug("[MakeShip] Forbidden (403) from {Url}. Headers: {Headers}. Body: {Body}", + url, headers, Truncate(body, 500)); + } + else { + logger?.LogWarning("[MakeShip] Forbidden (403) from {Url}", url); + } + return null; + } + if (!response.IsSuccessStatusCode) { logger?.LogDebug("[MakeShip] {Status} from {Url}", response.StatusCode, url); return null; diff --git a/SubathonManager.Integration/MixItUpService.cs b/SubathonManager.Integration/MixItUpService.cs index 88543867..8c6da15f 100644 --- a/SubathonManager.Integration/MixItUpService.cs +++ b/SubathonManager.Integration/MixItUpService.cs @@ -1,10 +1,8 @@ using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; -using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; @@ -22,7 +20,7 @@ public class MixItUpService( ILogger? logger, IConfig config, IHttpClientFactory httpClientFactory, - ITimerService timerService) : IAppService, IDisposable { + ITimerService timerService) : IAppService, IDisposable, IActionStepRunner { public const string ConfigSection = "MixItUp"; public const string DefaultApiUrl = "http://localhost:8911/api/v2"; @@ -42,10 +40,8 @@ public class MixItUpService( private readonly Lock _lock = new(); - private bool _lastLocked; - private MultiplierSnapshot? _lastMultiplier; - private bool _lastPaused; - private Guid? _trackedSubathonId; + private SubathonTriggerWatcher? _watcher; + private SubathonTriggerWatcher Watcher => _watcher ??= new SubathonTriggerWatcher(OnTrigger); public static string CommandsFolder => Path.GetFullPath(Path.Combine("external", "mixitup")); @@ -63,11 +59,11 @@ public string ApiUrl { } } - public static string CommandConfigKey(MixItUpTrigger trigger) { + public static string CommandConfigKey(SubathonTrigger trigger) { return $"Command.{trigger}"; } - public string GetCommandId(MixItUpTrigger trigger) { + public string GetCommandId(SubathonTrigger trigger) { return (config.Get(ConfigSection, CommandConfigKey(trigger), "") ?? "").Trim(); } @@ -86,8 +82,6 @@ public Task StopAsync(CancellationToken ct = default) { timerService.Unregister(SeenTimerKey); lock (_lock) { LastSeen = null; - _trackedSubathonId = null; - _lastMultiplier = null; } BroadcastStatus(); @@ -103,24 +97,12 @@ public void Dispose() { private void Subscribe() { IntegrationEvents.ExternalSourceSeen += OnExternalSourceSeen; - SubathonEvents.SubathonEventProcessed += OnSubathonEventProcessed; - SubathonEvents.SubathonDataUpdate += OnSubathonDataUpdate; - SubathonEvents.SubathonGoalCompleted += OnGoalCompleted; - SubathonEvents.PromptRunStarted += OnPromptRunStarted; - SubathonEvents.PromptRunUpdate += OnPromptRunUpdate; - WheelEvents.WheelSpinStarted += OnWheelSpinStarted; - WheelEvents.WheelSpinResult += OnWheelSpinResult; + Watcher.Start(); } private void Unsubscribe() { IntegrationEvents.ExternalSourceSeen -= OnExternalSourceSeen; - SubathonEvents.SubathonEventProcessed -= OnSubathonEventProcessed; - SubathonEvents.SubathonDataUpdate -= OnSubathonDataUpdate; - SubathonEvents.SubathonGoalCompleted -= OnGoalCompleted; - SubathonEvents.PromptRunStarted -= OnPromptRunStarted; - SubathonEvents.PromptRunUpdate -= OnPromptRunUpdate; - WheelEvents.WheelSpinStarted -= OnWheelSpinStarted; - WheelEvents.WheelSpinResult -= OnWheelSpinResult; + _watcher?.Stop(); } public async Task ProbeAsync(CancellationToken ct = default) { @@ -222,10 +204,31 @@ public async Task RunCommandAsync(Guid commandId, IReadOnlyDictionary StepTypes { get; } = [ActionStepType.MixItUpCommand]; + + public async Task RunStepAsync(ActionStep step, ActionContext ctx, ActionRunProgress progress, + CancellationToken ct) { + if (!Enabled || !Guid.TryParse(step.Target, out Guid commandId)) return false; + var identifiers = new Dictionary { + [$"{IdentifierPrefix}trigger"] = "action", + [$"{IdentifierPrefix}user"] = ctx.User, + [$"{IdentifierPrefix}source"] = $"{ctx.Source}", + [$"{IdentifierPrefix}label"] = ctx.Label ?? "" + }; + + foreach ((string name, string value) in step.BodyArguments()) { + string key = name.TrimStart('$').ToLowerInvariant(); + if (key.Length > 0) identifiers[key] = value; + } + + return await RunCommandAsync(commandId, identifiers, ct); + } public async Task?> GetCommandsAsync(CancellationToken ct = default) { var result = new List(); @@ -291,7 +294,7 @@ private sealed class CommandEntry { public bool IsEnabled { get; set; } } - internal bool Fire(MixItUpTrigger trigger, Dictionary identifiers) { + internal bool Fire(SubathonTrigger trigger, Dictionary identifiers) { if (!Enabled) return false; if (!Guid.TryParse(GetCommandId(trigger), out Guid commandId)) return false; @@ -300,243 +303,48 @@ internal bool Fire(MixItUpTrigger trigger, Dictionary identifier return true; } - public Task TestTriggerAsync(MixItUpTrigger trigger, string commandIdText, CancellationToken ct = default) { + public Task TestTriggerAsync(SubathonTrigger trigger, string commandIdText, CancellationToken ct = default) { if (!Guid.TryParse(commandIdText.Trim(), out Guid commandId)) return Task.FromResult(false); Dictionary identifiers = SampleIdentifiers(trigger); identifiers[$"{IdentifierPrefix}trigger"] = trigger.ToString(); return RunCommandAsync(commandId, identifiers, ct); } - private void OnSubathonEventProcessed(SubathonEvent subathonEvent, bool effective) { - if (!config.GetBool("App", "ShowLockedEvents", false) && !subathonEvent.ProcessedToSubathon) return; - if (subathonEvent.EventType == SubathonEventType.Command && !IncludeCommands) return; - Fire(MixItUpTrigger.SubathonEvent, EventIdentifiers(subathonEvent)); - } - - private void OnSubathonDataUpdate(SubathonData subathon, DateTime timestamp) { - // check for multiplier and pause/lock info - var fired = new List<(MixItUpTrigger, Dictionary)>(3); - MultiplierSnapshot? multiplier = subathon.Multiplier?.SubathonId == subathon.Id - ? MultiplierSnapshot.From(subathon.Multiplier) - : null; - lock (_lock) { - if (_trackedSubathonId != subathon.Id) { - _trackedSubathonId = subathon.Id; - _lastPaused = subathon.IsPaused; - _lastLocked = subathon.IsLocked; - _lastMultiplier = multiplier; - return; - } - - if (subathon.IsPaused != _lastPaused) { - _lastPaused = subathon.IsPaused; - fired.Add((subathon.IsPaused ? MixItUpTrigger.TimerPaused : MixItUpTrigger.TimerResumed, - TimerIdentifiers(subathon))); - } - - if (subathon.IsLocked != _lastLocked) { - _lastLocked = subathon.IsLocked; - fired.Add((subathon.IsLocked ? MixItUpTrigger.TimerLocked : MixItUpTrigger.TimerUnlocked, - TimerIdentifiers(subathon))); - } - - if (multiplier != null) { - MultiplierSnapshot? previous = _lastMultiplier; - _lastMultiplier = multiplier; - if (previous != null) { - if (multiplier.Running && multiplier != previous) - fired.Add((MixItUpTrigger.MultiplierStarted, MultiplierIdentifiers(multiplier))); - else if (!multiplier.Running && previous.Running) - fired.Add((MixItUpTrigger.MultiplierEnded, MultiplierIdentifiers(previous))); - } - } + private void OnTrigger(SubathonTrigger trigger, Dictionary values, SubathonEvent? subathonEvent) { + if (subathonEvent != null) { + if (!config.GetBool("App", "ShowLockedEvents", false) && !subathonEvent.ProcessedToSubathon) return; + if (subathonEvent.EventType == SubathonEventType.Command && !IncludeCommands) return; } - foreach ((MixItUpTrigger trigger, Dictionary identifiers) in fired) - Fire(trigger, identifiers); + Fire(trigger, Prefixed(values)); } - private void OnGoalCompleted(SubathonGoal goal, long currentValue) { - Fire(MixItUpTrigger.GoalCompleted, new Dictionary { - [$"{IdentifierPrefix}goaltext"] = goal.Text, - [$"{IdentifierPrefix}goaltarget"] = ToValueStr(goal.Points), - [$"{IdentifierPrefix}goalcurrent"] = ToValueStr(currentValue) - }); - } - - private void OnWheelSpinStarted(WheelSet wheel, int delaySeconds) { - Fire(MixItUpTrigger.WheelSpinStart, new Dictionary { - [$"{IdentifierPrefix}wheelname"] = wheel.Name, - [$"{IdentifierPrefix}wheelid"] = wheel.Id.ToString(), - [$"{IdentifierPrefix}spindelay"] = ToValueStr(delaySeconds) - }); - } - - private void OnWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int spinsOwed) { - Fire(MixItUpTrigger.WheelSpinEnd, new Dictionary { - [$"{IdentifierPrefix}wheelname"] = wheel.Name, - [$"{IdentifierPrefix}wheelid"] = wheel.Id.ToString(), - [$"{IdentifierPrefix}wheelitem"] = item?.Text ?? "", - [$"{IdentifierPrefix}spinstatus"] = history.Status.ToString(), - [$"{IdentifierPrefix}spinsowed"] = ToValueStr(spinsOwed) - }); - } - - private void OnPromptRunStarted(SubathonPromptRun run, SubathonPrompt? prompt) { - Fire(MixItUpTrigger.PromptStarted, PromptIdentifiers(run, prompt)); - } - - private void OnPromptRunUpdate(SubathonPromptRun run, SubathonPrompt? prompt) { - if (run.IsActive) return; - Fire(MixItUpTrigger.PromptEnded, PromptIdentifiers(run, prompt)); - } - - private static string ToValueStr(IFormattable value) { - return value.ToString(null, CultureInfo.InvariantCulture); + private static Dictionary Prefixed(Dictionary values) { + return values.ToDictionary(kv => $"{IdentifierPrefix}{kv.Key}", kv => kv.Value); } internal static Dictionary EventIdentifiers(SubathonEvent subathonEvent) { - var eventType = $"{subathonEvent.EventType}"; - string? trueSource = subathonEvent.EventType.GetTypeTrueSource(subathonEvent.EventTypeMeta); - if (subathonEvent.EventType == SubathonEventType.GoAffProOrder - && GoAffProOrderHelper.TryGetStore(subathonEvent.EventTypeMeta, out GoAffProStore? store)) { - trueSource = store.InternalName; - eventType = store.InternalEventName; - } - - double seconds = subathonEvent.GetFinalSecondsValueRaw() < 0.5 ? 0 : subathonEvent.GetFinalSecondsValue(); - return new Dictionary { - [$"{IdentifierPrefix}eventtype"] = eventType, - [$"{IdentifierPrefix}source"] = $"{subathonEvent.Source}", - [$"{IdentifierPrefix}truesource"] = trueSource ?? "", - [$"{IdentifierPrefix}subtype"] = $"{subathonEvent.EventType.GetSubType()}", - [$"{IdentifierPrefix}user"] = subathonEvent.User ?? "", - [$"{IdentifierPrefix}value"] = subathonEvent.Value, - [$"{IdentifierPrefix}amount"] = ToValueStr(subathonEvent.Amount), - [$"{IdentifierPrefix}currency"] = subathonEvent.Currency ?? "", - [$"{IdentifierPrefix}command"] = $"{subathonEvent.Command}", - [$"{IdentifierPrefix}secondsadded"] = ToValueStr(seconds), - [$"{IdentifierPrefix}pointsadded"] = ToValueStr(subathonEvent.GetFinalPointsValue()), - [$"{IdentifierPrefix}secondaryvalue"] = subathonEvent.SecondaryValue, - [$"{IdentifierPrefix}tertiaryvalue"] = subathonEvent.TertiaryValue, - [$"{IdentifierPrefix}reversed"] = $"{subathonEvent.WasReversed}" - }; + return Prefixed(SubathonTriggerWatcher.EventValues(subathonEvent)); } internal static Dictionary TimerIdentifiers(SubathonData subathon) { - TimeSpan remaining = subathon.TimeRemainingRounded(); - return new Dictionary { - [$"{IdentifierPrefix}timeremaining"] = - $"{(int)remaining.TotalHours:00}:{remaining.Minutes:00}:{remaining.Seconds:00}", - [$"{IdentifierPrefix}secondsremaining"] = ToValueStr((long)remaining.TotalSeconds), - [$"{IdentifierPrefix}points"] = ToValueStr(subathon.Points), - [$"{IdentifierPrefix}paused"] = $"{subathon.IsPaused}", - [$"{IdentifierPrefix}locked"] = $"{subathon.IsLocked}" - }; - } - - internal sealed record MultiplierSnapshot( - bool Running, - double Multiplier, - bool Time, - bool Points, - TimeSpan? Duration, - DateTime? Started, - bool FromHypeTrain) { - public static MultiplierSnapshot From(MultiplierData data) { - return new MultiplierSnapshot(data.IsRunning(), data.Multiplier, data.ApplyToSeconds, - data.ApplyToPoints, data.Duration, data.Started, data.FromHypeTrain); - } + return Prefixed(SubathonTriggerWatcher.TimerValues(subathon)); } internal static Dictionary MultiplierIdentifiers(MultiplierSnapshot multiplier) { - return new Dictionary { - [$"{IdentifierPrefix}multiplier"] = ToValueStr(multiplier.Multiplier), - [$"{IdentifierPrefix}multipliertime"] = $"{multiplier.Time}", - [$"{IdentifierPrefix}multiplierpoints"] = $"{multiplier.Points}", - [$"{IdentifierPrefix}multiplierduration"] = ToValueStr((long)(multiplier.Duration?.TotalSeconds ?? 0)), - [$"{IdentifierPrefix}multiplierhypetrain"] = $"{multiplier.FromHypeTrain}" - }; + return Prefixed(SubathonTriggerWatcher.MultiplierValues(multiplier)); } internal static Dictionary PromptIdentifiers(SubathonPromptRun run, SubathonPrompt? prompt) { - prompt ??= run.LinkedPrompt; - return new Dictionary { - [$"{IdentifierPrefix}prompttext"] = prompt?.Text ?? "", - [$"{IdentifierPrefix}prompttype"] = $"{prompt?.Type}", - [$"{IdentifierPrefix}prompttarget"] = ToValueStr(prompt?.Value ?? run.SnapshotTargetValue), - [$"{IdentifierPrefix}promptduration"] = - ToValueStr((long)(prompt?.CompletionDuration.TotalSeconds ?? (run.ExpiresAt - run.StartedAt).TotalSeconds)), - [$"{IdentifierPrefix}promptstatus"] = $"{run.Status}" - }; + return Prefixed(SubathonTriggerWatcher.PromptValues(run, prompt)); } - public static IReadOnlyList IdentifierNames(MixItUpTrigger trigger) { + public static IReadOnlyList IdentifierNames(SubathonTrigger trigger) { return SampleIdentifiers(trigger).Keys.Prepend($"{IdentifierPrefix}trigger").Select(k => $"${k}").ToList(); } - internal static Dictionary SampleIdentifiers(MixItUpTrigger trigger) { - switch (trigger) { - case MixItUpTrigger.SubathonEvent: - return EventIdentifiers(new SubathonEvent { - Source = SubathonEventSource.Simulated, - EventType = SubathonEventType.ExternalDonation, - User = "TestUser", - Value = "5", - Currency = "USD", - SecondsValue = 600, - PointsValue = 5, - ProcessedToSubathon = true - }); - case MixItUpTrigger.MultiplierStarted: - case MixItUpTrigger.MultiplierEnded: - return MultiplierIdentifiers(new MultiplierSnapshot(trigger == MixItUpTrigger.MultiplierStarted, - 2, true, true, TimeSpan.FromMinutes(10), DateTime.Now, false)); - case MixItUpTrigger.TimerPaused: - case MixItUpTrigger.TimerResumed: - case MixItUpTrigger.TimerLocked: - case MixItUpTrigger.TimerUnlocked: - return new Dictionary { - [$"{IdentifierPrefix}timeremaining"] = "12:34:56", - [$"{IdentifierPrefix}secondsremaining"] = "45296", - [$"{IdentifierPrefix}points"] = "250", - [$"{IdentifierPrefix}paused"] = (trigger == MixItUpTrigger.TimerPaused).ToString(), - [$"{IdentifierPrefix}locked"] = (trigger == MixItUpTrigger.TimerLocked).ToString() - }; - case MixItUpTrigger.GoalCompleted: - return new Dictionary { - [$"{IdentifierPrefix}goaltext"] = "Test Goal", - [$"{IdentifierPrefix}goaltarget"] = "100", - [$"{IdentifierPrefix}goalcurrent"] = "100" - }; - case MixItUpTrigger.WheelSpinStart: - return new Dictionary { - [$"{IdentifierPrefix}wheelname"] = "Test Wheel", - [$"{IdentifierPrefix}wheelid"] = Guid.Empty.ToString(), - [$"{IdentifierPrefix}spindelay"] = "0" - }; - case MixItUpTrigger.WheelSpinEnd: - return new Dictionary { - [$"{IdentifierPrefix}wheelname"] = "Test Wheel", - [$"{IdentifierPrefix}wheelid"] = Guid.Empty.ToString(), - [$"{IdentifierPrefix}wheelitem"] = "Test Item", - [$"{IdentifierPrefix}spinstatus"] = "Pending", - [$"{IdentifierPrefix}spinsowed"] = "0" - }; - case MixItUpTrigger.PromptStarted: - case MixItUpTrigger.PromptEnded: - return new Dictionary { - [$"{IdentifierPrefix}prompttext"] = "Test Prompt", - [$"{IdentifierPrefix}prompttype"] = $"{SubathonPromptType.Points}", - [$"{IdentifierPrefix}prompttarget"] = "10", - [$"{IdentifierPrefix}promptduration"] = "300", - [$"{IdentifierPrefix}promptstatus"] = trigger == MixItUpTrigger.PromptStarted - ? $"{SubathonPromptRunStatus.Active}" - : $"{SubathonPromptRunStatus.Completed}" - }; - default: - return new Dictionary(); - } + internal static Dictionary SampleIdentifiers(SubathonTrigger trigger) { + return Prefixed(SubathonTriggerWatcher.SampleValues(trigger)); } } diff --git a/SubathonManager.Integration/OBSService.cs b/SubathonManager.Integration/OBSService.cs index f8aac0db..1cca12f6 100644 --- a/SubathonManager.Integration/OBSService.cs +++ b/SubathonManager.Integration/OBSService.cs @@ -31,7 +31,7 @@ public sealed record ObsBrowserSourceCard( Guid? RouteId); [ExcludeFromCodeCoverage] -public class OBSService : IAppService { +public class OBSService : IAppService, IActionStepRunner { private const string HelperHotkeyName = "subathonmanager_apply_tweaks"; private const string HelperVersionHotkeyPrefix = "subathonmanager_version_"; private const string ManagedMarkerKey = "subathon_managed"; @@ -724,4 +724,211 @@ public async Task AddBrowserSource( await TryApplyHelperTweaksAsync(); BrowserSourcesChanged?.Invoke(); } + + ////////////////////////// action steps + + public IReadOnlyCollection StepTypes { get; } = [ + ActionStepType.ObsSourceVisibility, + ActionStepType.ObsFilter, + ActionStepType.ObsAudio, + ActionStepType.ObsMedia, + ActionStepType.ObsBrowserRefresh, + ActionStepType.ObsRaw + ]; + + public Task RunStepAsync(ActionStep step, ActionContext ctx, ActionRunProgress progress, + CancellationToken ct) { + if (!_obs.IsConnected) return Task.FromResult(false); + return Task.Run(() => { + try { + return RunStep(step, progress); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[OBSService] Action \"{Step}\" failed", step.Describe()); + return false; + } + }, ct); + } + + private bool RunStep(ActionStep step, ActionRunProgress progress) { + switch (step.Type) { + case ActionStepType.ObsSourceVisibility: { + (string scene, int id)? item = FindSceneItem(step.Scope ?? "", step.Target, []); + if (item == null) { + _logger?.LogWarning("[OBSService] Source \"{Source}\" not found in \"{Scene}\"", step.Target, + step.Scope); + return false; + } + + bool visible = step.Operation switch { + ActionOperation.Show => true, + ActionOperation.Hide => false, + _ => !(_obs.SendRequest("GetSceneItemEnabled", new JObject { + ["sceneName"] = item.Value.scene, ["sceneItemId"] = item.Value.id + })?["sceneItemEnabled"]?.Value() ?? false) + }; + _obs.SendRequest("SetSceneItemEnabled", new JObject { + ["sceneName"] = item.Value.scene, ["sceneItemId"] = item.Value.id, ["sceneItemEnabled"] = visible + }); + return true; + } + + case ActionStepType.ObsFilter: { + var filter = new JObject { ["sourceName"] = step.Scope, ["filterName"] = step.Target }; + bool enabled = step.Operation switch { + ActionOperation.Enable => true, + ActionOperation.Disable => false, + _ => !(_obs.SendRequest("GetSourceFilter", filter)?["filterEnabled"]?.Value() ?? false) + }; + filter["filterEnabled"] = enabled; + _obs.SendRequest("SetSourceFilterEnabled", filter); + return true; + } + + case ActionStepType.ObsAudio: + if (step.Operation == ActionOperation.Toggle) + _obs.SendRequest("ToggleInputMute", new JObject { ["inputName"] = step.Target }); + else + _obs.SendRequest("SetInputMute", new JObject { + ["inputName"] = step.Target, ["inputMuted"] = step.Operation == ActionOperation.Mute + }); + return true; + + case ActionStepType.ObsMedia: + _obs.SendRequest("TriggerMediaInputAction", new JObject { + ["inputName"] = step.Target, + ["mediaAction"] = step.Operation switch { + ActionOperation.Pause => "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PAUSE", + ActionOperation.Restart => "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART", + ActionOperation.Stop => "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP", + _ => "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY" + } + }); + return true; + + case ActionStepType.ObsBrowserRefresh: + _obs.SendRequest("PressInputPropertiesButton", new JObject { + ["inputName"] = step.Target, ["propertyName"] = "refreshnocache" + }); + return true; + + case ActionStepType.ObsRaw: + return RunRawRequest(step, progress); + + default: + return false; + } + } + + private bool RunRawRequest(ActionStep step, ActionRunProgress progress) { + string? output = string.IsNullOrEmpty(step.OutputVariable) ? null : step.OutputVariable; + try { + JObject? data = string.IsNullOrWhiteSpace(step.Body) ? null : JObject.Parse(step.Body); + JObject? response = _obs.SendRequest(step.Target.Trim(), data); + if (output != null) + progress.SetOutput(output, response?.ToString(Newtonsoft.Json.Formatting.None) ?? "{}", "200"); + return true; + } + catch (Exception ex) when (ex is ErrorResponseException or Newtonsoft.Json.JsonException) { + if (output != null) progress.SetOutput(output, "", "0"); + _logger?.LogWarning("[OBSService] Raw request {Request} failed: {Message}", step.Target, ex.Message); + return false; + } + } + + private List AvailableRequests() { + try { + return (_obs.SendRequest("GetVersion")?["availableRequests"] as JArray)? + .Select(r => r.ToString()).Order(StringComparer.OrdinalIgnoreCase).ToList() ?? []; + } + catch (Exception ex) { + if (_logger?.IsEnabled(LogLevel.Debug) ?? false) + _logger?.LogDebug(ex, "[OBSService] Listing request types failed"); + return []; + } + } + + private (string scene, int id)? FindSceneItem(string sceneName, string sourceName, HashSet visited) { + if (string.IsNullOrWhiteSpace(sceneName) || !visited.Add(sceneName)) return null; + JArray? items = ListSceneItems(sceneName, visited.Count > 1); + if (items == null) return null; + + foreach (JToken item in items) + if (string.Equals(item["sourceName"]?.ToString(), sourceName, StringComparison.Ordinal)) + return (sceneName, item["sceneItemId"]?.Value() ?? -1); + + foreach (JToken item in items.Where(i => i["isGroup"]?.Value() == true)) + if (FindSceneItem(item["sourceName"]?.ToString() ?? "", sourceName, visited) is { } nested) + return nested; + + return null; + } + + private JArray? ListSceneItems(string sceneName, bool isGroup) { + try { + return _obs.SendRequest(isGroup ? "GetGroupSceneItemList" : "GetSceneItemList", + new JObject { ["sceneName"] = sceneName })?["sceneItems"] as JArray; + } + catch (Exception ex) { + if (_logger?.IsEnabled(LogLevel.Debug) ?? false) + _logger?.LogDebug(ex, "[OBSService] Failed to list scene items for '{Scene}'", sceneName); + return null; + } + } + + public (List scopes, List targets) GetActionTargets(ActionStepType type, string? scope) { + if (!_obs.IsConnected) return ([], []); + try { + List<(string name, string kind)> inputs = _obs.GetInputList() + .Select(i => (i.InputName, i.InputKind)).ToList(); + + switch (type) { + case ActionStepType.ObsSourceVisibility: { + List scenes = GetScenes(); + var sources = new List(); + + if (!string.IsNullOrWhiteSpace(scope)) CollectSourceNames(scope, false, sources, []); + + return (scenes, sources.Distinct().ToList()); + } + case ActionStepType.ObsFilter: { + List owners = inputs.Select(i => i.name).Concat(GetScenes()).Distinct().ToList(); + + if (string.IsNullOrWhiteSpace(scope)) return (owners, []); + var filters = _obs.SendRequest("GetSourceFilterList", new JObject { ["sourceName"] = scope })? + ["filters"] as JArray; + + return (owners, filters?.Select(f => f["filterName"]?.ToString() ?? "") + .Where(n => n.Length > 0).ToList() ?? []); + } + case ActionStepType.ObsMedia: + return ([], inputs.Where(i => i.kind.Contains("ffmpeg") || i.kind.Contains("vlc")) + .Select(i => i.name).ToList()); + case ActionStepType.ObsBrowserRefresh: + return ([], inputs.Where(i => i.kind.StartsWith("browser_source", StringComparison.Ordinal)) + .Select(i => i.name).ToList()); + case ActionStepType.ObsRaw: + return ([], AvailableRequests()); + default: + return ([], inputs.Select(i => i.name).ToList()); + } + } + catch (Exception ex) { + if (_logger?.IsEnabled(LogLevel.Debug) ?? false) + _logger?.LogDebug(ex, "[OBSService] Listing action targets failed"); + return ([], []); + } + } + + private void CollectSourceNames(string sceneName, bool isGroup, List names, HashSet visited) { + if (!visited.Add(sceneName)) return; + JArray? items = ListSceneItems(sceneName, isGroup); + if (items == null) return; + foreach (JToken item in items) { + var name = item["sourceName"]?.ToString(); + if (string.IsNullOrEmpty(name)) continue; + names.Add(name); + if (item["isGroup"]?.Value() == true) CollectSourceNames(name, true, names, visited); + } + } } \ No newline at end of file diff --git a/SubathonManager.Integration/PallyService.cs b/SubathonManager.Integration/PallyService.cs index 258657ac..43bbcd7d 100644 --- a/SubathonManager.Integration/PallyService.cs +++ b/SubathonManager.Integration/PallyService.cs @@ -252,7 +252,7 @@ internal static Guid GuidFromString(string input) { } public static void SimulateTip(string value = "10.00") { - if (!double.TryParse(value, out double val) || val <= 0) return; + if (!Utils.TryParseAmount(value, out double val) || val <= 0) return; SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { User = "SYSTEM", Currency = "USD", diff --git a/SubathonManager.Integration/PatreonService.cs b/SubathonManager.Integration/PatreonService.cs new file mode 100644 index 00000000..30cf2f83 --- /dev/null +++ b/SubathonManager.Integration/PatreonService.cs @@ -0,0 +1,511 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Agash.Webhook.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Patreon.Client; +using Patreon.Client.Events; +using Patreon.Client.JsonApi; +using Patreon.Client.Models; +using Patreon.Client.Webhooks; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Core.Security; +using SubathonManager.Core.Security.Interfaces; +using SubathonManager.Services; + +namespace SubathonManager.Integration; + +public class PatreonService( + ILogger? logger, + IConfig config, + IHttpClientFactory httpClientFactory, + DevTunnelsService devTunnels, + ISecureStorage secureStorage, + OAuthService oAuth) : IWebhookIntegration { + private const string ApiBase = "https://www.patreon.com/api/oauth2/v2/"; + internal const string PledgeCreated = "members:pledge:create"; + internal const string PledgeUpdated = "members:pledge:update"; + internal const string MemberUpdated = "members:update"; + + // patreon tokens last ~30d, set to under to be safe + private static readonly OAuthProvider OAuthKeys = new("patreon", StorageKeys.PatreonAccessToken, + StorageKeys.PatreonRefreshToken, StorageKeys.PatreonTokenExpiry, + DefaultLifetime: TimeSpan.FromDays(30), RefreshMargin: TimeSpan.FromDays(4)); + + internal static readonly IReadOnlyList Triggers = [PledgeCreated, PledgeUpdated, MemberUpdated]; + internal static readonly TimeSpan LiveWindow = TimeSpan.FromMinutes(30); + + private readonly PatreonWebhookHandler _handler = new(new PatreonWebhookSignatureVerifier()); + private readonly SemaphoreSlim _initLock = new(1, 1); + + // tier id -> (title, cents), refreshed from webhook payloads so renames follow the id + // cents stored is for simulating/testing + internal readonly ConcurrentDictionary Tiers = new(); + private string? _campaignId; + private string _currency = "USD"; + + private bool ByAmount => config.GetBool(nameof(SubathonEventSource.Patreon), + $"{SubathonEventType.PatreonPledge}.ValueByAmount"); + + public string WebhookPath => "/api/webhooks/patreon"; + + public async Task StartAsync(CancellationToken ct = default) { + IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; + if (HasTokens()) { + logger?.LogInformation("[Patreon] Webhook listener ready at {Path}", WebhookPath); + _ = devTunnels.StartTunnelAsync(ct); + } + else { + logger?.LogInformation("[Patreon] Has not been setup before. Integration is disabled"); + BroadcastStatus(null); + } + + await Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct = default) { + IntegrationEvents.ConnectionUpdated -= OnTunnelUpdated; + BroadcastStatus(null); + return Task.CompletedTask; + } + + public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, + CancellationToken ct = default) { + string? secret = secureStorage.GetOrDefault(StorageKeys.PatreonWebhookSecret, string.Empty); + if (string.IsNullOrWhiteSpace(secret)) { + logger?.LogWarning("[Patreon] Received a webhook but no signing secret is stored. Recreate the webhook"); + return; + } + + Dictionary requestHeaders = new(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair header in headers) requestHeaders[header.Key] = [header.Value]; + + var request = new WebhookRequest { + Method = "POST", + Path = WebhookPath, + Body = rawBody, + ContentType = requestHeaders.TryGetValue("Content-Type", out string[]? type) ? type[0] : "application/json", + Headers = requestHeaders + }; + + WebhookHandleResult result = + await _handler.HandleAsync(request, new PatreonWebhookOptions { WebhookSecret = secret }, ct); + if (!result.IsAuthenticated) { + logger?.LogWarning("[Patreon] Rejected webhook: {Reason}", result.FailureReason); + return; + } + + // string body = Encoding.UTF8.GetString(rawBody); + // logger?.LogInformation("[Patreon] Received {Type} webhook: {Body}", result.Event?.EventType, body); + + (MemberAttributes? member, IReadOnlyList tierIds, IReadOnlyList? included) = + result.Event switch { + PatreonPledgeWebhookEvent { EventType: PledgeCreated or PledgeUpdated } p => + (p.Attributes, p.EntitledTierIds, p.Document?.Included), + PatreonMemberWebhookEvent { EventType: MemberUpdated } m => + (m.Attributes, m.EntitledTierIds, m.Document?.Included), + _ => (null, [], null) + }; + if (member == null || string.IsNullOrWhiteSpace(result.Event?.ResourceId)) { + if (logger?.IsEnabled(LogLevel.Debug) ?? false) + logger?.LogDebug("[Patreon] Ignoring {Type} webhook", result.Event?.EventType); + return; + } + + var index = new JsonApiIncludedIndex(included); + SubathonEvent? ev = MapPledge(result.Event.EventType, result.Event.ResourceId, member, tierIds, index, + PatronVanity(member.FullName, index) ?? member.FullName?.Split(" ").First() ?? "Patreon Member", + DateTimeOffset.UtcNow); + if (ev == null) return; + + SubathonEvents.RaiseSubathonEventCreated(ev); + if (logger?.IsEnabled(LogLevel.Debug) ?? false) + logger?.LogDebug("[Patreon] Raised pledge {Id} from {User} ({Value})", ev.Id, ev.User, ev.Value); + } + + public bool HasTokens() { + return oAuth.HasTokens(OAuthKeys); + } + + [ExcludeFromCodeCoverage] + public async Task ConnectAsync(CancellationToken ct = default) { + if (!await oAuth.AuthorizeAsync(OAuthKeys, ct)) { + BroadcastStatus(null); + return; + } + + IntegrationEvents.ConnectionUpdated -= OnTunnelUpdated; + IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; + await InitializeAsync(ct); + } + + [ExcludeFromCodeCoverage] + public async Task DisconnectAsync(CancellationToken ct = default) { + string? webhookId = secureStorage.GetOrDefault(StorageKeys.PatreonWebhookId, string.Empty); + if (!string.IsNullOrWhiteSpace(webhookId) && HasTokens()) + try { + await CreateApiClient().DeleteWebhookAsync(webhookId, ct); + logger?.LogInformation("[Patreon] Deleted webhook {Id}", webhookId); + } + catch (Exception ex) { + logger?.LogWarning(ex, "[Patreon] Couldn't delete webhook {Id}, it may need removing on Patreon", + webhookId); + } + + await StopAsync(ct); + oAuth.RevokeTokens(OAuthKeys); + secureStorage.Delete(StorageKeys.PatreonWebhookId); + secureStorage.Delete(StorageKeys.PatreonWebhookSecret); + BroadcastStatus(null); + } + + [ExcludeFromCodeCoverage] + public async Task RecreateWebhookAsync(CancellationToken ct = default) { + secureStorage.Delete(StorageKeys.PatreonWebhookId); + secureStorage.Delete(StorageKeys.PatreonWebhookSecret); + await InitializeAsync(ct); + } + + [ExcludeFromCodeCoverage] + public async Task InitializeAsync(CancellationToken ct = default) { + if (!await _initLock.WaitAsync(0, ct)) return; + try { + await InitializeCoreAsync(ct); + } + catch (Exception ex) { + logger?.LogError(ex, "[Patreon] Failed to initialize"); + ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Patreon), + $"Patreon failed to connect: {ex.Message}", DateTime.Now); + BroadcastStatus(null); + } + finally { + _initLock.Release(); + } + } + + private HttpClient CreateHttpClient() { + HttpClient http = httpClientFactory.CreateClient(nameof(PatreonService)); + http.BaseAddress = new Uri(ApiBase); + http.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", oAuth.GetAccessToken(OAuthKeys)); + http.DefaultRequestHeaders.UserAgent.ParseAdd("SubathonManager"); + return http; + } + + private PatreonApiClient CreateApiClient() { + return new PatreonApiClient(CreateHttpClient(), NullLogger.Instance); + } + + [ExcludeFromCodeCoverage] + private async Task<(string? Id, string? Secret)> CreateWebhookAsync(string url, CancellationToken ct) { + var payload = new { + data = new { + type = "webhook", + attributes = new { triggers = Triggers, uri = url }, + relationships = new { campaign = new { data = new { type = "campaign", id = _campaignId } } } + } + }; + + using HttpClient http = CreateHttpClient(); + using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + using HttpResponseMessage response = await http.PostAsync("webhooks", content, ct); + string body = await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) return (null, null); + + using JsonDocument doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("data", out JsonElement data)) { + logger?.LogWarning("[Patreon] Webhook create response had no data: {Body}", body); + return (null, null); + } + + string? id = data.TryGetProperty("id", out JsonElement idEl) ? idEl.GetString() : null; + string? secret = data.TryGetProperty("attributes", out JsonElement attrs) && + attrs.TryGetProperty("secret", out JsonElement secretEl) + ? secretEl.GetString() + : null; + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(secret)) + logger?.LogWarning("[Patreon] Webhook create response was missing {Missing}: {Body}", + string.IsNullOrWhiteSpace(id) ? "the id" : "the secret", body); + return (id, secret); + } + + [ExcludeFromCodeCoverage] + private async Task InitializeCoreAsync(CancellationToken ct) { + if (await devTunnels.RequireTunnelAsync(SubathonEventSource.Patreon, ct) is not { } tunnelConn) { + BroadcastStatus(null); + return; + } + + if (!HasTokens() || (oAuth.NeedsRefresh(OAuthKeys) && !await oAuth.RefreshAsync(OAuthKeys, ct))) { + logger?.LogWarning("[Patreon] Login is missing or expired"); + ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventSource.Patreon), + "Patreon login has expired. Reconnect it in the Patreon settings.", DateTime.Now); + BroadcastStatus(null); + return; + } + + PatreonApiClient client = CreateApiClient(); + JsonApiCollectionDocument? campaigns = + await client.GetCampaignsAsync(["name", "currency"], cancellationToken: ct); + JsonApiResource? campaign = campaigns?.Data?.Count > 0 ? campaigns?.Data?[0] : null; + + if (campaign == null) { + logger?.LogWarning("[Patreon] The connected account has no campaign"); + ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventSource.Patreon), + "The connected Patreon account doesn't have a creator campaign.", DateTime.Now); + BroadcastStatus(null); + return; + } + + _campaignId = campaign.Id; + if (!string.IsNullOrWhiteSpace(campaign.Attributes?.Currency)) _currency = campaign.Attributes.Currency; + logger?.LogInformation("[Patreon] Connected to campaign {Name}", campaign.Attributes?.Name); + + IReadOnlyList>? tiers = + await client.GetCampaignTiersAsync(campaign.Id, ["title", "amount_cents", "published"], ct); + Tiers.Clear(); + + foreach (JsonApiResource tier in tiers ?? []) + if (tier.Attributes is { AmountCents: > 0 } attrs && !string.IsNullOrWhiteSpace(attrs.Title)) + Tiers[tier.Id] = (attrs.Title, attrs.AmountCents); + IntegrationEvents.RaiseMembershipTiersSynced(SubathonEventSource.Patreon, + Tiers.Values.Select(t => t.Title).ToList()); + + string url = tunnelConn.Name.TrimEnd('/') + WebhookPath; + if (!await EnsureWebhookAsync(client, url, ct)) { + BroadcastStatus(null); + return; + } + + BroadcastStatus(tunnelConn.Name); + } + + [ExcludeFromCodeCoverage] + private async Task EnsureWebhookAsync(PatreonApiClient client, string url, CancellationToken ct) { + string? storedId = secureStorage.GetOrDefault(StorageKeys.PatreonWebhookId, string.Empty); + string? secret = secureStorage.GetOrDefault(StorageKeys.PatreonWebhookSecret, string.Empty); + + IReadOnlyList> hooks = (await client.GetWebhooksAsync(ct))?.Data ?? []; + + JsonApiResource? ours = string.IsNullOrWhiteSpace(storedId) + ? null + : hooks.FirstOrDefault(h => h.Id == storedId); + + if (ours != null && !string.IsNullOrWhiteSpace(secret)) { + WebhookAttributes? attrs = ours.Attributes; + bool triggersMatch = attrs?.Triggers != null && Triggers.All(attrs.Triggers.Contains); + if (attrs?.Uri == url && !attrs.Paused && triggersMatch) return true; + + JsonApiDocument? updated = null; + try { + updated = await client.UpdateWebhookAsync(ours.Id, false, url, Triggers, ct); + } + catch (Exception ex) { + logger?.LogWarning(ex, "[Patreon] Updating webhook {Id} threw", ours.Id); + } + + if (updated?.Data != null) { + logger?.LogInformation("[Patreon] Updated webhook {Id} (url changed, paused, or triggers changed)", + ours.Id); + return true; + } + + logger?.LogWarning("[Patreon] Couldn't update webhook {Id}, recreating it", ours.Id); + } + + foreach (JsonApiResource stale in hooks.Where(h => + h.Attributes?.Uri?.EndsWith(WebhookPath, StringComparison.OrdinalIgnoreCase) == true)) + try { + await client.DeleteWebhookAsync(stale.Id, ct); + logger?.LogInformation("[Patreon] Removed stale webhook {Id} ({Uri})", stale.Id, stale.Attributes?.Uri); + } + catch (Exception ex) { + logger?.LogWarning(ex, "[Patreon] Couldn't remove stale webhook {Id}", stale.Id); + } + + (string? newId, string? newSecret) = await CreateWebhookAsync(url, ct); + if (string.IsNullOrWhiteSpace(newId) || string.IsNullOrWhiteSpace(newSecret)) { + logger?.LogWarning("[Patreon] Couldn't create a webhook for {Url} on campaign {Campaign}", url, + _campaignId); + ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Patreon), + "Couldn't create the Patreon webhook, check the log for Patreon's reply. " + + "Try Recreate Webhook in the Patreon settings.", DateTime.Now); + return false; + } + + secureStorage.Set(StorageKeys.PatreonWebhookId, newId); + secureStorage.Set(StorageKeys.PatreonWebhookSecret, newSecret); + logger?.LogInformation("[Patreon] Created webhook {Id}", newId); + return true; + } + + internal SubathonEvent? MapPledge(string eventType, string memberId, MemberAttributes? member, + IReadOnlyList tierIds, JsonApiIncludedIndex included, string? name, DateTimeOffset now) { + if (member is not { PatronStatus: "active_patron", IsFreeTrial: false }) return null; + + int willPay = member.WillPayAmountCents; + int entitled = member.CurrentlyEntitledAmountCents; + // possibly a downgrade if weillPay is 0 + if (willPay <= 0 || entitled <= 0) return null; + + // get highest if multiple come for some reason + string tier = tierIds + .Select(id => ResolveTier(included, id)) + .OfType<(string Title, int Cents)>() + .OrderByDescending(t => t.Cents) + .Select(t => t.Title) + .FirstOrDefault() ?? "DEFAULT"; + string currency = CampaignCurrency(included); + + // annual or other term cadence: will_pay is the whole cadence while entitled is per month + // if tier mode we multiply the config for tier by cadence amount + // if amount/mode mode, we ignore cadence as amount is amount charged/paid at time of + int cadence = member.PledgeCadence ?? 1; + + DateTimeOffset? charged = Utils.ParseDateFromString(member.LastChargeDate); + var chargeKey = $"patreon|{memberId}|charge|{member.LastChargeDate}"; + + if (eventType == PledgeCreated) { + if (!string.IsNullOrEmpty(member.LastChargeStatus) && !IsPaid(member.LastChargeStatus)) return null; + return BuildPledgeEvent( + charged != null ? chargeKey : $"patreon|{memberId}|start|{member.PledgeRelationshipStart}", + name, tier, willPay, currency, cadence, Utils.ParseDateFromString(member.PledgeRelationshipStart) ?? now); + } + + // charge date needed for members:update to be within window + // created and update for pledge can be null just bc it's surely a pledge, but also, their test webhooks have it null... + if (charged != null && (!IsPaid(member.LastChargeStatus) || charged < now - LiveWindow)) return null; + if (eventType == MemberUpdated) + return charged is { } paidAt + ? BuildPledgeEvent(chargeKey, name, tier, willPay, currency, cadence, paidAt) + : null; + + // assumes an upgrade shows will_pay below entitled so only the difference counts + // unless its upgrade to annual, then will pay is above likely. Either way we don't care much + // we get the amount charged anyways + return BuildPledgeEvent(chargeKey, name, tier, willPay, currency, + cadence, charged ?? now); + } + + private static string? PatronVanity(string? fullName, JsonApiIncludedIndex included) { + foreach (JsonElement user in included.GetAllOfType("user")) { + JsonElement attrs = user.TryGetProperty("attributes", out JsonElement nested) ? nested : user; + string? tryName = Utils.GetJsonString(attrs, "full_name"); + if (!string.IsNullOrWhiteSpace(tryName) && + tryName.Equals(fullName, StringComparison.InvariantCultureIgnoreCase) && + Utils.GetJsonString(attrs, "vanity") is { Length: > 0 } vanity) + return vanity; + } + + return null; + } + + private static bool IsPaid(string? status) { + return string.Equals(status, "Paid", StringComparison.OrdinalIgnoreCase); + } + + private string CampaignCurrency(JsonApiIncludedIndex included) { + foreach (JsonElement campaign in included.GetAllOfType("campaign")) { + JsonElement attrs = campaign.TryGetProperty("attributes", out JsonElement nested) ? nested : campaign; + if (Utils.GetJsonString(attrs, "currency") is { Length: > 0 } currency) return currency; + } + + return _currency; + } + + private (string Title, int Cents)? ResolveTier(JsonApiIncludedIndex included, string id) { + JsonElement attrs = default; + if (included.TryGet("tier", id, out JsonElement tier)) + attrs = tier.TryGetProperty("attributes", out JsonElement nested) ? nested : tier; + if (attrs.ValueKind != JsonValueKind.Object || Utils.GetJsonString(attrs, "title") is not { Length: > 0 } title) + return Tiers.TryGetValue(id, out (string Title, int Cents) known) ? known : null; + + int cents = attrs.TryGetProperty("amount_cents", out JsonElement amount) && amount.TryGetInt32(out int c) + ? c + : Tiers.TryGetValue(id, out (string Title, int Cents) cached) ? cached.Cents : 0; + bool newTitle = Tiers.Values.All(t => t.Title != title); + Tiers[id] = (title, cents); + if (newTitle) + IntegrationEvents.RaiseMembershipTiersSynced(SubathonEventSource.Patreon, + Tiers.Values.Select(t => t.Title).ToList()); + return (title, cents); + } + + private SubathonEvent BuildPledgeEvent(string idKey, string? name, string tierName, int cents, + string? currency, int months, DateTimeOffset timestamp) { + string user = string.IsNullOrWhiteSpace(name) || name.Equals("Patreon Member") ? "Patreon Member" : name; + string chargedIn = string.IsNullOrWhiteSpace(currency) ? _currency : currency; + var amount = (cents / 100.0).ToString("F2", CultureInfo.InvariantCulture); + bool byAmount = ByAmount; + + return new SubathonEvent { + Id = Utils.CreateGuidFromUniqueString(idKey), + Source = user == "SYSTEM" ? SubathonEventSource.Simulated : SubathonEventSource.Patreon, + EventType = SubathonEventType.PatreonPledge, + User = user, + Value = byAmount ? amount : tierName, + EventTypeMeta = byAmount ? Utils.PerUnitMeta : tierName, + Currency = byAmount ? chargedIn : "member", + Amount = byAmount ? 1 : Math.Max(months, 1), + SecondaryValue = $"{amount}|{chargedIn}", + TertiaryValue = tierName, + EventTimestamp = timestamp.LocalDateTime + }; + } + + public void SimulateMembership(string tierName, bool annual) { + int cents = Tiers.Values.FirstOrDefault(t => t.Title == tierName).Cents is > 0 and var known ? known : 500; + SubathonEvent ev = BuildPledgeEvent($"patreon|simulated|{Guid.NewGuid()}", "SYSTEM", tierName, + annual ? cents * 12 : cents, _currency, annual ? 12 : 1, DateTimeOffset.Now); + SubathonEvents.RaiseSubathonEventCreated(ev); + } + + [ExcludeFromCodeCoverage] + private void OnTunnelUpdated(IntegrationConnection connection) { + if (connection is not { Source: SubathonEventSource.DevTunnels, Service: "Tunnel" }) return; + if (HasTokens() && connection.Status) { + Task.Run(() => InitializeAsync()); + return; + } + + BroadcastStatus(null); + } + + private void BroadcastStatus(string? tunnelBaseUrl) { + string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" + ? tunnelBaseUrl.TrimEnd('/') + WebhookPath + : null; + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Name = fullUrl ?? "", + Status = HasTokens() && fullUrl != null, + Source = SubathonEventSource.Patreon, + Service = nameof(SubathonEventSource.Patreon), + Configured = HasTokens() + }); + } + + [ExcludeFromCodeCoverage] + public sealed class ErrorLoggingHandler(ILogger? logger) : DelegatingHandler { + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) { + HttpResponseMessage response = await base.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode) return response; + + await response.Content.LoadIntoBufferAsync(cancellationToken); + string body = await response.Content.ReadAsStringAsync(cancellationToken); + logger?.LogWarning("[Patreon] {Method} {Url} returned HTTP {Status}: {Body}", request.Method, + request.RequestUri, (int)response.StatusCode, body.Length > 2000 ? body[..2000] : body); + return response; + } + } +} \ No newline at end of file diff --git a/SubathonManager.Integration/StreamElementsService.cs b/SubathonManager.Integration/StreamElementsService.cs index bdacc1dc..eeb566d4 100644 --- a/SubathonManager.Integration/StreamElementsService.cs +++ b/SubathonManager.Integration/StreamElementsService.cs @@ -232,7 +232,7 @@ public void Disconnect() { } public static void SimulateTip(string value = "10.00", string currency = "USD") { - if (!double.TryParse(value, out double val)) + if (!Utils.TryParseAmount(value, out double val)) return; SubathonEvent subathonEvent = new() { diff --git a/SubathonManager.Integration/StreamLabsService.cs b/SubathonManager.Integration/StreamLabsService.cs index e2da91f7..ee5b5e5e 100644 --- a/SubathonManager.Integration/StreamLabsService.cs +++ b/SubathonManager.Integration/StreamLabsService.cs @@ -109,7 +109,7 @@ private void OnDonation(object? o, DonationMessage message) { } public static void SimulateTip(string value = "10.00", string currency = "USD") { - if (!double.TryParse(value, out double val)) + if (!Utils.TryParseAmount(value, out double val)) return; var subathonEvent = new SubathonEvent { diff --git a/SubathonManager.Integration/StreamerBotService.cs b/SubathonManager.Integration/StreamerBotService.cs new file mode 100644 index 00000000..1a9a3c97 --- /dev/null +++ b/SubathonManager.Integration/StreamerBotService.cs @@ -0,0 +1,152 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Json; +using System.Text.Json; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Objects; + +namespace SubathonManager.Integration; + +[ExcludeFromCodeCoverage] +public sealed record StreamerBotActionInfo(Guid Id, string Name, string Group) { + public string DisplayName => string.IsNullOrWhiteSpace(Group) ? Name : $"{Group} / {Name}"; +} + +// Separate from the SB extension +// Communicates over the HTTP Server api in SB +public class StreamerBotService( + ILogger? logger, + IConfig config, + IHttpClientFactory httpClientFactory, + ITimerService timerService) : IAppService, IActionStepRunner { + public const string ConfigSection = "StreamerBot"; + public const string DefaultHttpUrl = "http://127.0.0.1:7474"; + public const string ArgumentPrefix = "subathonmanager"; + public const string HttpService = "Http"; + private const string HttpCheckTimerKey = "StreamerBot.HttpCheck"; + private static readonly TimeSpan HttpCheckInterval = TimeSpan.FromSeconds(120); + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + private bool? _lastHttpStatus; + private bool _lastHttpEnabled; + + public bool HttpEnabled => config.GetBool(ConfigSection, "Http.Enabled"); + + public string HttpUrl { + get { + string url = (config.Get(ConfigSection, "Http.Url", DefaultHttpUrl) ?? "").Trim().TrimEnd('/'); + return string.IsNullOrWhiteSpace(url) ? DefaultHttpUrl : url; + } + } + + public IReadOnlyCollection StepTypes { get; } = [ActionStepType.StreamerBotAction]; + + private HttpClient CreateClient() { + HttpClient client = httpClientFactory.CreateClient(nameof(StreamerBotService)); + client.Timeout = TimeSpan.FromSeconds(5); + return client; + } + + public Task StartAsync(CancellationToken ct = default) { + timerService.Register(HttpCheckTimerKey, HttpCheckInterval, () => _ = CheckHttpAsync(ct)); + _ = Task.Run(() => CheckHttpAsync(ct), ct); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct = default) { + timerService.Unregister(HttpCheckTimerKey); + return Task.CompletedTask; + } + + public async Task CheckHttpAsync(CancellationToken ct = default) { + if (HttpEnabled) await GetActionsAsync(ct); + else ReportHttpStatus(false); + } + + private void ReportHttpStatus(bool reachable) { + bool enabled = HttpEnabled; + if (_lastHttpStatus == reachable && _lastHttpEnabled == enabled) return; + _lastHttpStatus = reachable; + _lastHttpEnabled = enabled; + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Source = SubathonEventSource.StreamerBot, + Service = HttpService, + Name = HttpUrl, + Status = reachable && enabled, + Configured = enabled + }); + } + + public async Task?> GetActionsAsync(CancellationToken ct = default) { + try { + using HttpClient client = CreateClient(); + ActionList? list = await client.GetFromJsonAsync($"{HttpUrl}/GetActions", JsonOptions, ct); + ReportHttpStatus(true); + return (list?.Actions ?? []) + .Where(a => a.Id != Guid.Empty) + .Select(a => new StreamerBotActionInfo(a.Id, a.Name ?? "", a.Group ?? "")) + .OrderBy(a => a.Group, StringComparer.OrdinalIgnoreCase) + .ThenBy(a => a.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (Exception ex) { + if (!ct.IsCancellationRequested) ReportHttpStatus(false); + if (logger?.IsEnabled(LogLevel.Debug) ?? false) + logger?.LogDebug("[StreamerBot] GetActions failed: {Message}", ex.Message); + return null; + } + } + + public async Task DoActionAsync(Guid actionId, string? actionName, IReadOnlyDictionary args, + CancellationToken ct = default) { + try { + var payload = new { + action = new { id = actionId, name = actionName }, + args + }; + using HttpClient client = CreateClient(); + using HttpResponseMessage response = await client.PostAsJsonAsync($"{HttpUrl}/DoAction", payload, ct); + ReportHttpStatus(true); + if (response.IsSuccessStatusCode) return true; + logger?.LogWarning("[StreamerBot] Running action {Action} failed with {Status}", actionName ?? $"{actionId}", + (int)response.StatusCode); + return false; + } + catch (Exception ex) { + if (!ct.IsCancellationRequested) ReportHttpStatus(false); + logger?.LogWarning("[StreamerBot] Running action {Action} failed: {Message}", actionName ?? $"{actionId}", + ex.Message); + return false; + } + } + + public async Task RunStepAsync(ActionStep step, ActionContext ctx, ActionRunProgress progress, + CancellationToken ct) { + if (!HttpEnabled || !Guid.TryParse(step.Target, out Guid actionId)) return false; + + var args = new Dictionary(StringComparer.Ordinal) { + [$"{ArgumentPrefix}trigger"] = "action", + [$"{ArgumentPrefix}user"] = ctx.User, + [$"{ArgumentPrefix}source"] = $"{ctx.Source}", + [$"{ArgumentPrefix}label"] = ctx.Label ?? "" + }; + foreach ((string name, string value) in step.BodyArguments()) args[name] = value; + + return await DoActionAsync(actionId, step.TargetName, args, ct); + } + + private sealed class ActionList { + public List? Actions { get; set; } + } + + [UsedImplicitly] + private sealed class ActionEntry { + public Guid Id { get; set; } + public string? Name { get; set; } + public string? Group { get; set; } + } +} diff --git a/SubathonManager.Integration/SubathonManager.Integration.csproj b/SubathonManager.Integration/SubathonManager.Integration.csproj index efc58d0c..f0498eb3 100644 --- a/SubathonManager.Integration/SubathonManager.Integration.csproj +++ b/SubathonManager.Integration/SubathonManager.Integration.csproj @@ -14,10 +14,12 @@ + + diff --git a/SubathonManager.Integration/ThroneService.cs b/SubathonManager.Integration/ThroneService.cs index 7b258bee..002d1221 100644 --- a/SubathonManager.Integration/ThroneService.cs +++ b/SubathonManager.Integration/ThroneService.cs @@ -168,7 +168,7 @@ public void ProcessData(string stringData, bool isSim = false) { OrderTypeModes.Dollar); data.TryGetValue("price", out object? price); - double.TryParse(price?.ToString() ?? "0.00", out double priceInt); + Utils.TryParseAmount(price?.ToString() ?? "0.00", out double priceInt); subathonEvent.EventType = SubathonEventType.ThroneGiftPurchase; subathonEvent.Currency = mode == OrderTypeModes.Dollar @@ -184,13 +184,13 @@ public void ProcessData(string stringData, bool isSim = false) { subathonEvent.Currency = currency; data.TryGetValue("amount", out object? amount); subathonEvent.TertiaryValue = itemName?.ToString() ?? "New Contribution"; - double.TryParse(amount?.ToString(), out double amountInt); + Utils.TryParseAmount(amount?.ToString(), out double amountInt); subathonEvent.Value = (amountInt / 100).ToString("F2", CultureInfo.InvariantCulture); break; case "gift_crowdfunded": data.TryGetValue("price", out object? price2); - double.TryParse(price2?.ToString() ?? "0.00", out double priceInt2); + Utils.TryParseAmount(price2?.ToString() ?? "0.00", out double priceInt2); subathonEvent.EventType = SubathonEventType.ThroneCrowdGiftComplete; subathonEvent.User = itemName?.ToString() ?? "Crowdfunding Complete!"; subathonEvent.Currency = currency; diff --git a/SubathonManager.Integration/TiltifyService.cs b/SubathonManager.Integration/TiltifyService.cs index c35c9339..ad20773a 100644 --- a/SubathonManager.Integration/TiltifyService.cs +++ b/SubathonManager.Integration/TiltifyService.cs @@ -7,6 +7,7 @@ using Microsoft.Kiota.Abstractions.Authentication; using Microsoft.Kiota.Abstractions.Serialization; using Microsoft.Kiota.Http.HttpClientLibrary; +using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; @@ -24,7 +25,7 @@ public class TiltifyService( ILogger? logger, IConfig config, ITimerService timerService, - OAuthService oAuth) : IAppService, IDisposable { + OAuthService oAuth) : IAppService, IMissedEventSource, IDisposable { private const string ApiBase = "https://v5api.tiltify.com"; internal const string CampaignIdsKey = "CampaignIds"; internal const string TeamCampaignIdsKey = "TeamCampaignIds"; @@ -260,21 +261,36 @@ private async Task PollAsync(CancellationToken ct) { [ExcludeFromCodeCoverage] private async Task PollCampaignAsync(Guid campaignId, bool isTeam, DateTimeOffset since, CancellationToken ct) { if (_client == null) return; - var completedAfter = (since - PollOverlap).UtcDateTime - .ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); + List donations = await FetchDonationsAsync(campaignId, isTeam, since - PollOverlap, + MaxPagesPerPoll, ct); + + foreach (Donation donation in donations.OrderBy(d => d.CompletedAt ?? DateTimeOffset.MinValue)) { + if (donation.Id == null || donation.CompletedAt == null) continue; + if (donation.CompletedAt > _watermarks.GetValueOrDefault(campaignId, since)) + _watermarks[campaignId] = donation.CompletedAt.Value; + if (!_seen.TryAdd(donation.Id.Value, donation.CompletedAt.Value)) continue; + HandleDonation(donation, campaignId); + } + } + + [ExcludeFromCodeCoverage] + private async Task> FetchDonationsAsync(Guid campaignId, bool isTeam, + DateTimeOffset completedAfter, int maxPages, CancellationToken ct) { + if (_client == null) return []; + string after = completedAfter.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); string? cursor = null; List donations = []; - for (var page = 0; page < MaxPagesPerPoll; page++) { + for (var page = 0; page < maxPages; page++) { string? pageCursor = cursor; RequestInformation req = isTeam ? _client.Api.Public.Team_campaigns[campaignId.ToString()].Donations.ToGetRequestInformation(r => { - r.QueryParameters.CompletedAfter = completedAfter; + r.QueryParameters.CompletedAfter = after; r.QueryParameters.Limit = PageSize; r.QueryParameters.After = pageCursor; }) : _client.Api.Public.Campaigns[campaignId.ToString()].Donations.ToGetRequestInformation(r => { - r.QueryParameters.CompletedAfter = completedAfter; + r.QueryParameters.CompletedAfter = after; r.QueryParameters.Limit = PageSize; r.QueryParameters.After = pageCursor; }); @@ -285,13 +301,7 @@ private async Task PollCampaignAsync(Guid campaignId, bool isTeam, DateTimeOffse cursor = next; } - foreach (Donation donation in donations.OrderBy(d => d.CompletedAt ?? DateTimeOffset.MinValue)) { - if (donation.Id == null || donation.CompletedAt == null) continue; - if (donation.CompletedAt > _watermarks.GetValueOrDefault(campaignId, since)) - _watermarks[campaignId] = donation.CompletedAt.Value; - if (!_seen.TryAdd(donation.Id.Value, donation.CompletedAt.Value)) continue; - HandleDonation(donation, campaignId); - } + return donations; } [ExcludeFromCodeCoverage] @@ -306,9 +316,8 @@ private async Task PollCampaignAsync(Guid campaignId, bool isTeam, DateTimeOffse data.ValueKind == JsonValueKind.Array ? (await KiotaJsonSerializer.DeserializeCollectionAsync(data.GetRawText(), factory, ct)).ToList() : []; - string? after = doc.RootElement.TryGetProperty("metadata", out JsonElement meta) && - meta.TryGetProperty("after", out JsonElement a) && a.ValueKind == JsonValueKind.String - ? a.GetString() + string? after = doc.RootElement.TryGetProperty("metadata", out JsonElement meta) + ? Utils.GetJsonString(meta, "after") : null; return (items, after); } @@ -322,9 +331,18 @@ private void PruneSeen() { } internal void HandleDonation(Donation donation, Guid? polledCampaignId = null) { + foreach (SubathonEvent ev in MapDonation(donation, polledCampaignId)) { + SubathonEvents.RaiseSubathonEventCreated(ev); + if (logger?.IsEnabled(LogLevel.Debug) ?? false) + logger?.LogDebug("[Tiltify] Raised donation {Id} from {User}", ev.Id, ev.User); + } + } + + private List MapDonation(Donation donation, Guid? polledCampaignId = null) { + List events = []; try { if (!double.TryParse(donation.Amount?.Value, NumberStyles.Any, CultureInfo.InvariantCulture, - out double amount)) return; + out double amount)) return events; string? currency = donation.Amount?.Currency; var ev = new SubathonEvent { @@ -338,9 +356,7 @@ internal void HandleDonation(Donation donation, Guid? polledCampaignId = null) { EventTypeMeta = (donation.CampaignId ?? polledCampaignId)?.ToString(), TertiaryValue = GetCampaignName(donation.CampaignId) ?? GetCampaignName(polledCampaignId) ?? "" }; - - SubathonEvents.RaiseSubathonEventCreated(ev); - logger?.LogDebug("[Tiltify] Raised donation {Id} from {User}", ev.Id, ev.User); + events.Add(ev); foreach (DonationMatch match in donation.DonationMatches ?? []) { if (match.Active != true || match.Id == null) continue; @@ -348,7 +364,7 @@ internal void HandleDonation(Donation donation, Guid? polledCampaignId = null) { out double matchAmount) || matchAmount <= 0) continue; string? matchCurrency = match.Amount?.Currency; - var matchEv = new SubathonEvent { + events.Add(new SubathonEvent { Id = match.Id.Value, Source = SubathonEventSource.Tiltify, EventType = SubathonEventType.TiltifyDonation, @@ -358,20 +374,35 @@ internal void HandleDonation(Donation donation, Guid? polledCampaignId = null) { EventTimestamp = match.CompletedAt?.LocalDateTime ?? ev.EventTimestamp, EventTypeMeta = ev.EventTypeMeta, TertiaryValue = ev.TertiaryValue - }; - - SubathonEvents.RaiseSubathonEventCreated(matchEv); - logger?.LogDebug("[Tiltify] Raised donation match {Id} from {User} on donation {Donation}", - matchEv.Id, matchEv.User, ev.Id); + }); } } catch (Exception ex) { logger?.LogWarning(ex, "[Tiltify] Failed to consume donation {Id}", donation.Id); } + + return events; + } + + [ExcludeFromCodeCoverage] + public async Task> FetchMissedEventsAsync(DateTime from, DateTime to, + CancellationToken ct = default) { + if (_client == null) throw new InvalidOperationException("Tiltify is not connected"); + if (oAuth.NeedsRefresh(OAuthKeys)) await oAuth.RefreshAsync(OAuthKeys, ct); + + DateTimeOffset start = from.ToUniversalTime(); + DateTimeOffset end = to.ToUniversalTime(); + List events = []; + foreach ((Guid id, bool isTeam) in GetSelectedCampaigns()) + foreach (Donation donation in (await FetchDonationsAsync(id, isTeam, start, 1000, ct)) + .Where(donation => donation.CompletedAt <= end)) + events.AddRange(MapDonation(donation, id)); + + return events; } public static void SimulateDonation(string amount, string currency, CampaignOption? campaign = null) { - if (!double.TryParse(amount, NumberStyles.Any, CultureInfo.InvariantCulture, out double amt)) return; + if (!Utils.TryParseAmount(amount, out double amt)) return; var ev = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TiltifyDonation, diff --git a/SubathonManager.Integration/TipeeeStreamService.cs b/SubathonManager.Integration/TipeeeStreamService.cs index 692e7bd6..5883ed3c 100644 --- a/SubathonManager.Integration/TipeeeStreamService.cs +++ b/SubathonManager.Integration/TipeeeStreamService.cs @@ -341,8 +341,7 @@ public async Task ConnectAsync(CancellationToken ct = default) { } public static void SimulateDonation(string amount, string currency) { - if (!double.TryParse(amount, NumberStyles.Any, - CultureInfo.InvariantCulture, out double amt)) return; + if (!Utils.TryParseAmount(amount, out double amt)) return; var ev = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TipeeeStreamDonation, diff --git a/SubathonManager.Integration/VTSService.cs b/SubathonManager.Integration/VTSService.cs index d8d99c3e..c66cab28 100644 --- a/SubathonManager.Integration/VTSService.cs +++ b/SubathonManager.Integration/VTSService.cs @@ -42,9 +42,8 @@ public class VTSService( ILogger? logger, IConfig config, ISecureStorage secureStorage, - ITimerService? timerService = null, IDbContextFactory? dbFactory = null) - : IAppService, IDisposable { + : IAppService, IDisposable, IActionStepRunner { public const string ConfigSection = "VTubeStudio"; private const string PluginName = "Subathon Manager"; private const string PluginDeveloper = "WolfwithSword"; @@ -929,140 +928,90 @@ private async Task ApplyModelChangeAsync(string? modelId, string? modelName, Can await RefreshAsync(ct); } - public async Task ExecuteWheelActionAsync(VTSWheelAction action, CancellationToken ct = default) { - if (!Connected) { - _logger?.LogInformation( - "[VTSService] Wheel action skipped: not connected to VTube Studio. Leaving the spin pending"); - return false; - } - - if (!action.IsValid(out string invalid)) { - _logger?.LogWarning("[VTSService] Wheel action is not valid: {Error}", invalid); - return false; - } - - switch (action.Kind) { - case VtsTargetKind.Expression: - return await RunExpressionWheelActionAsync(action, ct); - case VtsTargetKind.Parameter: - return await RunParameterWheelActionAsync(action, ct); - case VtsTargetKind.Hotkey: - return await RunHotkeyWheelActionAsync(action, ct); - default: - return false; - } - } - - [ExcludeFromCodeCoverage] - private async Task RunExpressionWheelActionAsync(VTSWheelAction action, CancellationToken ct) { - if (!await TargetExistsAsync(action, ct)) return false; - if (!await ApplyExpressionActionAsync(action.Target, action.ToggleAction, ct)) return false; - ScheduleRevert(action, null); - return true; - } - - [ExcludeFromCodeCoverage] - private async Task RunParameterWheelActionAsync(VTSWheelAction action, CancellationToken ct) { - double? original = await GetParameterValueAsync(action.Target, ct); - if (original == null) { - _logger?.LogWarning( - "[VTSService] Wheel action target parameter \"{Parameter}\" not found on the current model", - action.Target); - return false; - } - - if (!await SetParameterValueAsync(action.Target, action.Value, ct: ct)) return false; - ScheduleRevert(action, original); - return true; - } - - [ExcludeFromCodeCoverage] - private async Task RunHotkeyWheelActionAsync(VTSWheelAction action, CancellationToken ct) { - if (!await TargetExistsAsync(action, ct)) return false; - if (!await TriggerHotkeyAsync(action.Target, ct: ct)) return false; - ScheduleRevert(action, null); - return true; - } + ////////////////////////////// action steps [ExcludeFromCodeCoverage] - private async Task TargetExistsAsync(VTSWheelAction action, CancellationToken ct) { + public async Task HasTargetAsync(VtsTargetKind kind, string target, CancellationToken ct = default) { + if (!Connected || string.IsNullOrWhiteSpace(target)) return false; if (Matches()) return true; await RefreshAsync(ct); if (Matches()) return true; - _logger?.LogWarning( - "[VTSService] Wheel action target {Kind} \"{Target}\" is not on the current model ({Model})", - action.Kind, action.Target, CurrentModelName ?? "none"); + _logger?.LogWarning("[VTSService] {Kind} \"{Target}\" is not on the current model ({Model})", + kind, target, CurrentModelName ?? "none"); return false; bool Matches() { - return action.Kind switch { + return kind switch { VtsTargetKind.Expression => CachedExpressions.Any(e => - string.Equals(e.File, action.Target, StringComparison.OrdinalIgnoreCase)), + string.Equals(e.File, target, StringComparison.OrdinalIgnoreCase)), VtsTargetKind.Hotkey => CachedHotkeys.Any(h => - string.Equals(h.Id, action.Target, StringComparison.OrdinalIgnoreCase)), + string.Equals(h.Id, target, StringComparison.OrdinalIgnoreCase)), VtsTargetKind.Parameter => CachedParameters.Any(p => - string.Equals(p.Name, action.Target, StringComparison.Ordinal)), + string.Equals(p.Name, target, StringComparison.Ordinal)), _ => false }; } } - [ExcludeFromCodeCoverage] - private void ScheduleRevert(VTSWheelAction action, double? originalValue) { - if (!action.HasRevert) return; - - if (timerService == null) { - _logger?.LogWarning("[VTSService] No timer service available; skipping the wheel action revert."); - return; - } - - string key = action.TimerKey; - timerService.Register(key, action.Duration, async revertCt => { - timerService.Unregister(key); - await RevertWheelActionAsync(action, originalValue, revertCt); - }); - - _logger?.LogInformation("[VTSService] Wheel action revert for {Target} scheduled in {Duration}", - action.Target, action.Duration); - } + public IReadOnlyCollection StepTypes { get; } = + [ActionStepType.VtsExpression, ActionStepType.VtsParameter, ActionStepType.VtsHotkey]; [ExcludeFromCodeCoverage] - private async Task RevertWheelActionAsync(VTSWheelAction action, double? originalValue, CancellationToken ct) { - if (!Connected) { - _logger?.LogInformation("[VTSService] Skipping wheel action revert for {Target}: not connected.", - action.Target); - return; - } - - switch (action.Kind) { - case VtsTargetKind.Expression: - await ApplyExpressionActionAsync(action.Target, action.AfterToggle, ct); + public async Task RunStepAsync(ActionStep step, ActionContext ctx, ActionRunProgress progress, + CancellationToken ct) { + if (!Connected) return false; + + switch (step.Type) { + case ActionStepType.VtsExpression: + return await HasTargetAsync(VtsTargetKind.Expression, step.Target, ct) + && await ApplyExpressionActionAsync(step.Target, step.Operation switch { + ActionOperation.Off => VtsToggleAction.Off, + ActionOperation.Toggle => VtsToggleAction.Toggle, + _ => VtsToggleAction.On + }, ct); + + case ActionStepType.VtsHotkey: + return await HasTargetAsync(VtsTargetKind.Hotkey, step.Target, ct) + && await TriggerHotkeyAsync(step.Target, ct: ct); + + case ActionStepType.VtsParameter: break; - case VtsTargetKind.Parameter: - switch (action.AfterParameter) { - case VtsParameterAfterAction.DoNothing: - ReleaseParameter(action.Target); - break; - case VtsParameterAfterAction.ResetToOriginal: - ReleaseParameter(action.Target); - if (originalValue.HasValue) - await SetParameterValueAsync(action.Target, originalValue.Value, hold: false, ct: ct); - break; - case VtsParameterAfterAction.SetNewValue: - ReleaseParameter(action.Target); - await SetParameterValueAsync(action.Target, action.AfterValue, hold: false, ct: ct); - break; + default: + return false; + } + + var originalKey = $"vts-parameter:{step.Target}"; + switch (step.Operation) { + case ActionOperation.Hold: + case ActionOperation.Set: { + // remember the value from before this run touched it, for a restore if applicable + if (!progress.TryGetValue(originalKey, out _)) { + double? original = await GetParameterValueAsync(step.Target, ct); + if (original == null) { + _logger?.LogWarning("[VTSService] Parameter \"{Parameter}\" not found on the current model", + step.Target); + return false; + } + + progress.Remember(originalKey, original.Value); } - break; + if (step.Operation == ActionOperation.Set) ReleaseParameter(step.Target); + return await SetParameterValueAsync(step.Target, step.Value ?? 0, + hold: step.Operation == ActionOperation.Hold, ct: ct); + } - case VtsTargetKind.Hotkey: - if (action.AfterHotkey == VtsHotkeyAfterAction.TriggerAgain) - await TriggerHotkeyAsync(action.Target, ct: ct); - break; + case ActionOperation.Restore: + ReleaseParameter(step.Target); + return !progress.TryGetValue(originalKey, out double value) + || await SetParameterValueAsync(step.Target, value, hold: false, ct: ct); + + default: + ReleaseParameter(step.Target); + return true; } } diff --git a/SubathonManager.Integration/YouTubeService.cs b/SubathonManager.Integration/YouTubeService.cs index c6bbcb3a..14d42338 100644 --- a/SubathonManager.Integration/YouTubeService.cs +++ b/SubathonManager.Integration/YouTubeService.cs @@ -420,7 +420,7 @@ protected virtual void Dispose(bool disposing) { } public static void SimulateSuperChat(string value = "10.00", string currency = "USD") { - if (!double.TryParse(value, out double val)) + if (!Utils.TryParseAmount(value, out double val)) return; var subathonEvent = new SubathonEvent { diff --git a/SubathonManager.Server/WebServer.Api.cs b/SubathonManager.Server/WebServer.Api.cs index 233a107b..b3cb7ce1 100644 --- a/SubathonManager.Server/WebServer.Api.cs +++ b/SubathonManager.Server/WebServer.Api.cs @@ -1,7 +1,9 @@ using System.Collections.Specialized; +using System.Globalization; using System.Text.Json; using System.Web; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SubathonManager.Core; using SubathonManager.Core.Enums; @@ -10,6 +12,7 @@ using SubathonManager.Data; using SubathonManager.Integration; using SubathonManager.Server.Interfaces; +using SubathonManager.Services; // ReSharper disable NullableWarningSuppressionIsUsed @@ -28,6 +31,11 @@ private void SetupApiRoutes() { _routes.Add((new RouteKey("GET", "/api/data/commands"), HandleCommandsRequestAsync)); + _routes.Add((new RouteKey("GET", "/api/data/globals"), HandleGlobalsRequestAsync)); + _routes.Add((new RouteKey("POST", "/api/data/globals"), HandleGlobalsSetRequestAsync)); + _routes.Add((new RouteKey("PUT", "/api/data/globals"), HandleGlobalsSetRequestAsync)); + _routes.Add((new RouteKey("PATCH", "/api/data/globals"), HandleGlobalsSetRequestAsync)); + _routes.Add((new RouteKey("GET", "/api/data/leaderboard"), HandleLeaderboardRequestAsync)); _routes.Add((new RouteKey("PUT", "/api/data/values"), HandleValuesPatchRequestAsync)); @@ -280,6 +288,136 @@ internal async Task HandleCommandsRequestAsync(IHttpContext ctx) { await ctx.WriteResponse(200, json, true, "application/json"); } + internal async Task HandleGlobalsRequestAsync(IHttpContext ctx) { + NameValueCollection query = HttpUtility.ParseQueryString(ctx.QueryString ?? string.Empty); + await using AppDbContext db = await _factory.CreateDbContextAsync(); + List globals = await LoadGlobalsAsync(db); + + if (query["widget"] is { } widgetParam) { + Widget? widget = Guid.TryParse(widgetParam, out Guid widgetId) + ? await db.Widgets.AsNoTracking().FirstOrDefaultAsync(w => w.Id == widgetId) + : null; + if (widget == null) { + await ctx.WriteResponse(404, "Widget not found"); + return; + } + + globals = globals.Where(g => widget.ListensToGlobal(g.Name)).ToList(); + } + + if (query["name"] is { } nameParam) { + var names = new HashSet(nameParam.Split(',', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); + globals = globals.Where(g => names.Contains(g.Name)).ToList(); + } + + string json = JsonSerializer.Serialize(new { globals = globals.Select(GlobalToObject) }); + await ctx.WriteResponse(200, json, true, "application/json"); + } + + internal async Task HandleGlobalsSetRequestAsync(IHttpContext ctx) { + async Task Fail(int code, string error) { + await ctx.WriteResponse(code, JsonSerializer.Serialize(new { error }), true, "application/json"); + } + + var actions = AppServices.Provider?.GetService(); + if (actions == null) { + await Fail(503, "Actions aren't available"); + return; + } + + NameValueCollection query = HttpUtility.ParseQueryString(ctx.QueryString ?? string.Empty); + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase) { + ["name"] = query["name"], ["type"] = query["type"], ["op"] = query["op"], ["value"] = query["value"] + }; + ActionValueType? sentType = null; + + string body; + using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) { + body = await reader.ReadToEndAsync(); + } + + if (!string.IsNullOrWhiteSpace(body)) + try { + using JsonDocument doc = JsonDocument.Parse(body); + if (doc.RootElement.ValueKind != JsonValueKind.Object) throw new JsonException(); + + foreach (JsonProperty prop in doc.RootElement.EnumerateObject()) { + fields[prop.Name] = prop.Value.ValueKind switch { + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Null or JsonValueKind.Undefined => null, + _ => prop.Value.GetRawText() + }; + + if (!prop.Name.Equals("value", StringComparison.OrdinalIgnoreCase)) continue; + sentType = prop.Value.ValueKind switch { + JsonValueKind.Number => ActionValueType.Number, + JsonValueKind.True or JsonValueKind.False => ActionValueType.Boolean, + _ => null + }; + } + } + catch (JsonException) { + await Fail(400, "Body must be a JSON object"); + return; + } + + string name = fields.GetValueOrDefault("name")?.Trim() ?? string.Empty; + if (name.Length == 0) { + await Fail(400, "name is required"); + return; + } + + if (fields.GetValueOrDefault("type") is { } typeText && !string.IsNullOrWhiteSpace(typeText)) { + ActionValueType? named = typeText.Trim().ToLowerInvariant() switch { + "text" or "string" => ActionValueType.Text, + "number" => ActionValueType.Number, + "boolean" or "bool" => ActionValueType.Boolean, + _ => null + }; + if (named == null || (sentType != null && sentType != named)) { + await Fail(400, named == null + ? $"Unknown type \"{typeText}\" (text, number or boolean)" + : $"value is a {sentType!.Value.GetLabel()} but type says {named.Value.GetLabel()}"); + return; + } + + sentType = named; + } + + string? value = fields.GetValueOrDefault("value"); + string op = (fields.GetValueOrDefault("op") ?? "set").Trim().ToLowerInvariant(); + ActionOperation? operation = op switch { + "set" => ActionOperation.Set, + "add" or "subtract" => ActionOperation.Adjust, + "toggle" => ActionOperation.Toggle, + _ => null + }; + if (operation == null) { + await Fail(400, $"Unknown operation \"{op}\" (set, add, subtract or toggle)"); + return; + } + + if (op == "subtract" && value != null) { + if (ActionValueType.Number.NormalizeValue(value) is not { } amount) { + await Fail(400, $"Can't subtract \"{value}\", it isn't a number"); + return; + } + + value = (-double.Parse(amount, CultureInfo.InvariantCulture)).ToString(CultureInfo.InvariantCulture); + } + + (ActionGlobal? global, bool created, string? errorStr) = + await actions.ChangeGlobalAsync(name, sentType, operation.Value, value, true); + if (global == null) { + await Fail(400, errorStr ?? "Could not set global"); + return; + } + + await ctx.WriteResponse(200, JsonSerializer.Serialize(new { created, global = GlobalToObject(global) }), true, + "application/json"); + } + private async Task HandleValuesRequestAsync(IHttpContext ctx) { string json = await _valueHelper.GetAllAsJsonAsync(); await ctx.WriteResponse(200, json); diff --git a/SubathonManager.Server/WebServer.WebSocket.cs b/SubathonManager.Server/WebServer.WebSocket.cs index 94d76c71..393ae017 100644 --- a/SubathonManager.Server/WebServer.WebSocket.cs +++ b/SubathonManager.Server/WebServer.WebSocket.cs @@ -43,6 +43,8 @@ private void SetupWebsocketListeners() { WheelEvents.WheelSpinResult += SendWheelSpinResult; WheelEvents.WheelSpinStatusChanged += SendWheelSpinStatusChanged; WheelEvents.WheelDataChanged += SendWheelDataChanged; + + ActionEvents.GlobalsUpdated += SendGlobalsUpdate; } private void StopWebsocketServer() { @@ -67,6 +69,8 @@ private void StopWebsocketServer() { WheelEvents.WheelSpinStatusChanged -= SendWheelSpinStatusChanged; WheelEvents.WheelDataChanged -= SendWheelDataChanged; + ActionEvents.GlobalsUpdated -= SendGlobalsUpdate; + List clientsCopy; lock (_lock) { clientsCopy = _clients.ToList(); @@ -101,6 +105,93 @@ internal void SendWidgetReload(Guid widgetId, float x, float y, int width, int h x, y, width, height, scaleX, scaleY }; BroadcastObject(data, WebsocketClientMessageType.Overlay); + _ = SendGlobalsSnapshotAsync(null, widgetId); + } + + ////////////////// globals for widgets + private readonly SemaphoreSlim _globalsSendLock = new(1, 1); + + internal static object GlobalToObject(ActionGlobal global) { + return new { + name = global.Name, + type = global.ValueType.GetJsonType(), + value = global.ValueType.ToTypedValue(global.Value) + }; + } + + internal static async Task> LoadGlobalsAsync(AppDbContext db) { + return (await db.ActionGlobals.AsNoTracking().Where(g => g.Kind == ActionStoreKind.Global).ToListAsync()) + .OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static object GlobalsMessage(Guid widgetId, bool full, IEnumerable globals, + IEnumerable removed) { + return new { + type = "globals_update", + widgetId = widgetId.ToString(), + full, + globals = globals.Select(GlobalToObject).ToArray(), + removed = removed.ToArray() + }; + } + + internal async Task SendGlobalsSnapshotAsync(IWebSocketClient? socket, Guid widgetId) { + try { + await _globalsSendLock.WaitAsync(); + try { + await using AppDbContext db = await _factory.CreateDbContextAsync(); + Widget? widget = await db.Widgets.AsNoTracking().FirstOrDefaultAsync(w => w.Id == widgetId); + if (widget == null || string.IsNullOrWhiteSpace(widget.GlobalVars)) return; + + object data = GlobalsMessage(widgetId, true, + (await LoadGlobalsAsync(db)).Where(g => widget.ListensToGlobal(g.Name)), []); + if (socket != null) await SelectSendAsync(socket, data); + else BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); + } + finally { + _globalsSendLock.Release(); + } + } + catch (Exception ex) { + _logger?.LogWarning(ex, "Failed to send globals to widget {WidgetId}", widgetId); + } + } + + internal void SendGlobalsUpdate(IReadOnlyCollection names) { + Task.Run(() => SendGlobalsUpdateAsync(names)); + } + + internal async Task SendGlobalsUpdateAsync(IReadOnlyCollection names) { + try { + await _globalsSendLock.WaitAsync(); + try { + await using AppDbContext db = await _factory.CreateDbContextAsync(); + List widgets = await db.Widgets.AsNoTracking().Where(w => w.GlobalVars != "").ToListAsync(); + if (widgets.Count == 0) return; + + var changed = new HashSet(names, StringComparer.OrdinalIgnoreCase); + Dictionary current = (await LoadGlobalsAsync(db)) + .Where(g => changed.Contains(g.Name)) + .ToDictionary(g => g.Name, StringComparer.OrdinalIgnoreCase); + + foreach (Widget widget in widgets) { + List wanted = changed.Where(widget.ListensToGlobal).ToList(); + + if (wanted.Count == 0) continue; + + BroadcastObject(GlobalsMessage(widget.Id, false, + wanted.Where(current.ContainsKey).Select(n => current[n]), + wanted.Where(n => !current.ContainsKey(n))), + WebsocketClientTypeHelper.ConsumersList); + } + } + finally { + _globalsSendLock.Release(); + } + } + catch (Exception ex) { + _logger?.LogWarning(ex, "Failed to send globals update"); + } } internal void SendPromptData(SubathonPromptRun? run, long progress = 0) { @@ -456,6 +547,11 @@ private async Task Listen(IWebSocketClient socket) { _logger?.LogDebug( $"[WebSocket] [{socket.ClientId}] Hello from {json.RootElement.GetProperty("origin").GetString()}"); break; + case "globals": + if (json.RootElement.TryGetProperty("widgetId", out JsonElement widgetEl) + && Guid.TryParse(widgetEl.GetString(), out Guid widgetGuid)) + await SendGlobalsSnapshotAsync(socket, widgetGuid); + break; } if (Enum.TryParse(type.GetString(), out clientMessageType)) @@ -575,7 +671,8 @@ when json.RootElement.TryGetProperty("data", out JsonElement data): { || !Guid.TryParse(idProp.GetString(), out Guid histId)) break; if (!json.RootElement.TryGetProperty("status", out JsonElement statusProp) - || !Enum.TryParse(statusProp.GetString(), true, out WheelSpinHistoryStatus newStatus)) + || !Enum.TryParse(statusProp.GetString(), true, out WheelSpinHistoryStatus newStatus) + || newStatus == WheelSpinHistoryStatus.Running) break; await using AppDbContext db = await _factory.CreateDbContextAsync(); @@ -749,11 +846,13 @@ public string GetWebsocketInjectionScript(string? routeId = "") { wheel_spin_result: 'handleWheelSpinResult', wheel_data: 'handleWheelData', wheel_spin_start: 'handleWheelSpinStart', - wheel_spin_status: 'handleWheelSpinStatus' + wheel_spin_status: 'handleWheelSpinStatus', + globals_update: 'handleGlobalVars' }; var listeners = {}; var state = {}; + var globals = {}; var socket = null, reconnectTimer = null, pingTimer = null, connected = false; var relayParent = null, relayTimer = null, relayTries = 0, gaveUpOnRelay = false; @@ -784,6 +883,7 @@ function callLegacy(data) { function dispatch(data) { if (!data || typeof data.type !== 'string') return; + if (data.type === 'globals_update' && !takeGlobals(data)) return; if (STATE_TYPES[data.type]) state[data.type] = data; builtin(data); @@ -793,6 +893,26 @@ function dispatch(data) { emit('*', data); } + function takeGlobals(data) { + if (IS_OVERLAY || !api.widgetId || data.widgetId !== api.widgetId) return false; + if (data.full) globals = {}; + var list = data.globals || [], removed = data.removed || [], i; + for (i = 0; i < removed.length; i++) delete globals[removed[i]]; + for (i = 0; i < list.length; i++) globals[list[i].name] = list[i]; + data.all = Object.assign({}, globals); + state.globals_update = { + type: 'globals_update', widgetId: data.widgetId, full: true, + globals: Object.keys(globals).map(function (k) { return globals[k]; }), + removed: [], all: data.all + }; + return true; + } + + function requestGlobals() { + if (IS_OVERLAY || !api.widgetId) return; + api.send({ ws_type: 'globals', widgetId: api.widgetId }); + } + function replayStateToLegacy() { for (var type in state) if (!legacyServed[type]) callLegacy(state[type]); @@ -836,6 +956,7 @@ function setConnected(isUp) { connected = isUp; if (IS_OVERLAY) relayPostAll({ kind: 'status', connected: isUp }); if (isUp) { + requestGlobals(); if (typeof window.handleSubathonConnect === 'function') safe(window.handleSubathonConnect); emit('connect', { type: 'connect' }); } else { @@ -995,6 +1116,9 @@ function openSocket() { get state() { return state; }, get: function (type) { return state[type] || null; }, + get globals() { return Object.assign({}, globals); }, + global: function (name) { return globals[name] ? globals[name].value : undefined; }, + on: function (type, fn, opts) { if (typeof fn !== 'function') return api; (listeners[type] || (listeners[type] = [])).push(fn); diff --git a/SubathonManager.Services/ActionService.cs b/SubathonManager.Services/ActionService.cs new file mode 100644 index 00000000..70d847db --- /dev/null +++ b/SubathonManager.Services/ActionService.cs @@ -0,0 +1,1292 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Core.Security; +using SubathonManager.Core.Security.Interfaces; +using SubathonManager.Data; +using SubathonManager.Data.Widgets; + +namespace SubathonManager.Services; + +public partial class ActionService( + IDbContextFactory factory, + IEnumerable runners, + ILogger? logger = null, + string? actionsFolder = null, + IConfig? config = null, + ISecureStorage? secureStorage = null) : IAppService, IDisposable { + public const string LoggedRunMeta = "logged-run"; + + public const string FromActionMeta = "from-action"; + + // web step saving to %resp% also sets %resp_status% + public const string StatusSuffix = ActionRunProgress.StatusSuffix; + + private static readonly HttpClient Http = new() { Timeout = Timeout.InfiniteTimeSpan }; + private static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan MaxRequestTimeout = TimeSpan.FromMinutes(5); + + private readonly string _folder = actionsFolder ?? DefaultActionsFolder; + private readonly SemaphoreSlim _globalsLock = new(1, 1); + private readonly ConcurrentDictionary _library = new(); + + private readonly Dictionary _runners = runners + .SelectMany(r => r.StepTypes.Select(t => (Type: t, Runner: r))) + .GroupBy(x => x.Type) + .ToDictionary(g => g.Key, g => g.First().Runner); + + private readonly ConcurrentDictionary _runs = new(); + private volatile bool _libraryLoaded; + private volatile bool _multiplierActive; + + public static string DefaultActionsFolder => Path.GetFullPath("actions"); + public static string ExportsFolder => Path.GetFullPath(Path.Combine("exports", "actions")); + + public IReadOnlyList CustomActions => + _library.Values.Select(e => e.Action).OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase).ToList(); + + public async Task StartAsync(CancellationToken ct = default) { + SubathonEvents.SubathonDataUpdate += OnSubathonDataUpdate; + WheelEvents.WheelSpinStatusChanged += OnWheelSpinStatusChanged; + ActionEvents.CustomActionRunRequested += OnCustomActionRunRequested; + SubathonEvents.PromptRunUpdate += OnPromptRunUpdate; + TriggerWatcher.Start(); + await LoadLibraryAsync(); + + await using AppDbContext db = await factory.CreateDbContextAsync(ct); + await db.WheelSpinHistories + .Where(h => h.Status == WheelSpinHistoryStatus.Running) + .ExecuteUpdateAsync(s => s.SetProperty(h => h.Status, WheelSpinHistoryStatus.Pending), ct); + await db.SubathonPromptRuns + .Where(r => r.ActionStatus == SubathonPromptActionStatus.Running) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.ActionStatus, SubathonPromptActionStatus.Failed), ct); + } + + public Task StopAsync(CancellationToken ct = default) { + Dispose(); + return Task.CompletedTask; + } + + public void Dispose() { + SubathonEvents.SubathonDataUpdate -= OnSubathonDataUpdate; + WheelEvents.WheelSpinStatusChanged -= OnWheelSpinStatusChanged; + ActionEvents.CustomActionRunRequested -= OnCustomActionRunRequested; + SubathonEvents.PromptRunUpdate -= OnPromptRunUpdate; + _triggerWatcher?.Stop(); + foreach (LiveRun run in _runs.Values) run.Cancel(); + GC.SuppressFinalize(this); + } + + public event Action? CustomActionsChanged; + + public bool IsRunning(Guid runId) { + return _runs.ContainsKey(runId); + } + + public void Cancel(Guid runId) { + if (_runs.TryGetValue(runId, out LiveRun? run)) run.Cancel(); + } + + public CustomAction? FindCustomAction(string name) { + string wanted = name.Trim(); + return _library.Values.Select(e => e.Action) + .FirstOrDefault(a => string.Equals(a.Name.Trim(), wanted, StringComparison.OrdinalIgnoreCase)); + } + + public async Task RunCustomActionAsync(CustomAction action, string user, + SubathonEventSource source) { + try { + return await RunAsync(Guid.NewGuid(), action.Graph, + new ActionContext(source, user, $"custom-action-{action.Id}", action.Name)); + } + catch (Exception ex) { + logger?.LogError(ex, "[Actions] Running {Name} failed", action.Name); + return ActionRunResult.Paused; + } + } + + private static void LogRun(string actionName, ActionContext ctx) { + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { + Source = ctx.Source, + EventTimestamp = DateTime.Now, + EventType = SubathonEventType.Command, + Command = SubathonCommandType.RunAction, + User = ctx.User, + Value = $"{SubathonCommandType.RunAction} {actionName}", + EventTypeMeta = LoggedRunMeta + }); + } + + public Task RunManuallyAsync(CustomAction action, bool unsavedCopy = false) { + var ctx = new ActionContext(SubathonEventSource.Simulated, "CustomAction", + unsavedCopy ? $"custom-action-test-{action.Id}" : $"custom-action-{action.Id}", action.Name); + LogRun(action.Name, ctx); + return RunAsync(Guid.NewGuid(), action.Graph, ctx); + } + + private void OnCustomActionRunRequested(SubathonEvent ev) { + if (ev.EventTypeMeta == LoggedRunMeta) return; + string name = ev.Value?.Trim() ?? ""; + var prefix = $"{SubathonCommandType.RunAction} "; + + if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) name = name[prefix.Length..].Trim(); + if (FindCustomAction(name) is not { } action) { + logger?.LogWarning("[Actions] RunAction asked for \"{Name}\", which is not in the actions library", name); + return; + } + + if (action.Disabled) { + logger?.LogInformation("[Actions] RunAction asked for disabled action: \"{Name}\"", action.Name); + return; + } + + string user = string.IsNullOrWhiteSpace(ev.User) ? "SYSTEM" : ev.User; + _ = RunCustomActionAsync(action, user, ev.Source); + } + + ////////////////// triggers + + private SubathonTriggerWatcher? _triggerWatcher; + private SubathonTriggerWatcher TriggerWatcher => _triggerWatcher ??= new SubathonTriggerWatcher(OnTrigger); + + public IEnumerable<(CustomAction Action, ActionNode Node)> TriggerSteps() { + return CustomActions.SelectMany(a => a.Graph.Nodes + .Where(n => n.Step.Type == ActionStepType.Trigger) + .Select(n => (a, n))); + } + + private void OnTrigger(SubathonTrigger trigger, Dictionary values, SubathonEvent? subathonEvent) { + if (subathonEvent != null) { + if (subathonEvent.EventTypeMeta is LoggedRunMeta or FromActionMeta) return; + if (!subathonEvent.ProcessedToSubathon && !(config?.GetBool("App", "ShowLockedEvents", false) ?? false)) + return; + } + + foreach (IGrouping matched in TriggerSteps() + .Where(t => !t.Action.Disabled && !t.Node.Disabled + && t.Node.Step.MatchesTrigger(trigger, subathonEvent)) + .GroupBy(t => t.Action)) { + HashSet nodes = matched.Select(t => t.Node.Id).ToHashSet(StringComparer.Ordinal); + _ = RunTriggeredAsync(matched.Key, trigger, nodes, values, subathonEvent); + } + } + + private async Task RunTriggeredAsync(CustomAction action, SubathonTrigger trigger, + IReadOnlySet nodes, IReadOnlyDictionary values, SubathonEvent? subathonEvent, + string? repeatKey = null) { + string user = subathonEvent?.User is { } named && !string.IsNullOrWhiteSpace(named) ? named : "SYSTEM"; + var ctx = new ActionContext(subathonEvent?.Source ?? SubathonEventSource.Unknown, user, + repeatKey ?? $"custom-action-{action.Id}", action.Name, trigger, nodes, values); + try { + LogRun(action.Name, ctx); + return await RunAsync(Guid.NewGuid(), action.Graph, ctx); + } + catch (Exception ex) { + logger?.LogError(ex, "[Actions] Running {Name} from {Trigger} failed", action.Name, trigger); + return ActionRunResult.Paused; + } + } + + ////////////////// actions switching and running other actions + public const int MaxActionDepth = 8; + + public CustomAction? ResolveActionTarget(ActionStep step) { + if (Guid.TryParse(step.Target.Trim(), out Guid id) && GetCustomAction(id) is { } byId) return byId; + string name = string.IsNullOrWhiteSpace(step.TargetName) ? step.Target : step.TargetName; + return string.IsNullOrWhiteSpace(name) ? null : FindCustomAction(name); + } + + public async Task SetCustomActionEnabledAsync(Guid id, bool enabled) { + if (GetCustomAction(id) is not { } action) return false; + if (action.Disabled != enabled) return true; + CustomAction copy = action.Clone(); + copy.Disabled = !enabled; + await SaveCustomActionAsync(copy); + logger?.LogInformation("[Actions] \"{Name}\" turned {State}", action.Name, enabled ? "on" : "off"); + return true; + } + + private async Task RunSetActionEnabledAsync(ActionStep step) { + if (ResolveActionTarget(step) is not { } target) { + logger?.LogWarning("[Actions] There is no action \"{Name}\" to turn on/off", step.TargetName ?? step.Target); + return false; + } + + bool enable = step.Operation switch { + ActionOperation.Enable => true, + ActionOperation.Disable => false, + _ => target.Disabled + }; + return await SetCustomActionEnabledAsync(target.Id, enable); + } + + private async Task RunOtherActionAsync(ActionStep step, ActionContext ctx, CancellationToken ct) { + if (ResolveActionTarget(step) is not { } target) { + logger?.LogWarning("[Actions] There is no action \"{Name}\" to run", step.TargetName ?? step.Target); + return false; + } + + if (target.Disabled) { + logger?.LogInformation("[Actions] Not running disabled action \"{Name}\"", target.Name); + return true; + } + + if (ctx.Depth >= MaxActionDepth) { + logger?.LogWarning("[Actions] Not running \"{Name}\": action(s) are running each other more than {Max} levels deep", + target.Name, MaxActionDepth); + return false; + } + + var child = new ActionContext(ctx.Source, ctx.User, $"custom-action-{target.Id}", target.Name, + Depth: ctx.Depth + 1); + LogRun(target.Name, child); + Task run = RunChildAsync(target, child); + if (step.Operation == ActionOperation.Start) return true; + + ActionRunResult result = await run.WaitAsync(ct); + return result is ActionRunResult.Done or ActionRunResult.Skipped; + } + + private async Task RunChildAsync(CustomAction action, ActionContext ctx) { + try { + return await RunAsync(Guid.NewGuid(), action.Graph, ctx); + } + catch (Exception ex) { + logger?.LogError(ex, "[Actions] Running {Name} from another action failed", action.Name); + return ActionRunResult.Paused; + } + } + + public Task TestTriggerAsync(CustomAction action, string nodeId, bool unsavedCopy = false) { + ActionNode? node = action.Graph.Nodes.FirstOrDefault(n => n.Id == nodeId); + if (node?.Step is not { Type: ActionStepType.Trigger, Trigger: { } trigger }) + return Task.FromResult(ActionRunResult.Skipped); + + Dictionary values = SubathonTriggerWatcher.SampleValues(trigger); + if (trigger == SubathonTrigger.SubathonEvent + && node.Step.EventTypes?.FirstOrDefault() is { } picked + && Enum.TryParse(picked, out SubathonEventType eventType)) { + values["eventtype"] = $"{eventType}"; + values["source"] = $"{SubathonEventSource.Simulated}"; + } + + values["user"] = values.GetValueOrDefault("user") is { Length: > 0 } user ? user : "CustomAction"; + return RunTriggeredAsync(action, trigger, new HashSet([nodeId]), values, + new SubathonEvent { Source = SubathonEventSource.Simulated, User = values["user"] }, + unsavedCopy ? $"custom-action-test-{action.Id}" : null); + } + + private async Task FillVariablesAsync(ActionStep step, ActionContext ctx, ActionRunProgress progress, + IReadOnlySet outputs) { + List tokens = ActionStepTypeHelper.FindTokens(step.AllText) + .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (tokens.Count == 0) return step; + + List builtIns = ActionStepTypeHelper.FindVariables(step.AllText).Distinct().ToList(); + Dictionary values = builtIns.Count == 0 + ? [] + : await ActionVariableResolver.ResolveAsync(factory, builtIns, ctx, config); + + List globalNames = ActionStepTypeHelper.FindStoreRefs(step.AllText) + .Where(r => r.Kind == ActionStoreKind.Global).Select(r => r.Name).ToList(); + Dictionary globals = globalNames.Count == 0 ? [] : await GetGlobalValuesAsync(globalNames); + + return step.Fill(text => ActionStepTypeHelper.ReplaceTokens(text, token => { + if (ActionStepTypeHelper.TryGetStoreRef(token, out ActionStoreKind kind, out string name)) + return (kind == ActionStoreKind.Secret ? GetSecretValue(name) : globals.GetValueOrDefault(name)) ?? ""; + if (progress.TryReadVariable(token, out string saved)) return saved; + + string root = ActionStepTypeHelper.TokenRoot(token); + if (outputs.Contains(root) || (root.EndsWith(StatusSuffix, StringComparison.OrdinalIgnoreCase) + && outputs.Contains(root[..^StatusSuffix.Length]))) return ""; + + return ActionStepTypeHelper.TryParseToken(token, out ActionVariable variable) + && values.TryGetValue(variable, out string? value) + ? value + : null; + })); + } + + ////////////////// globals & secrets + public event Action? GlobalsChanged; + + private void NotifyGlobals(params string[] globalNames) { + GlobalsChanged?.Invoke(); + ActionEvents.RaiseGlobalsUpdated(globalNames); + } + + private static string SecretKey(string name) { + return $"{StorageKeys.ActionSecretPrefix}{name.ToLowerInvariant()}"; + } + + public async Task> GetGlobalsAsync(ActionStoreKind? kind = null) { + await using AppDbContext db = await factory.CreateDbContextAsync(); + return await db.ActionGlobals.AsNoTracking() + .Where(g => kind == null || g.Kind == kind) + .OrderBy(g => g.Kind).ThenBy(g => g.Name) + .ToListAsync(); + } + + public async Task> GetGlobalTypesAsync() { + return (await GetGlobalsAsync(ActionStoreKind.Global)) + .ToDictionary(g => g.Name, g => g.ValueType, StringComparer.OrdinalIgnoreCase); + } + + private async Task> GetGlobalValuesAsync(List names) { + var wanted = new HashSet(names, StringComparer.OrdinalIgnoreCase); + await using AppDbContext db = await factory.CreateDbContextAsync(); + return (await db.ActionGlobals.AsNoTracking().Where(g => g.Kind == ActionStoreKind.Global).ToListAsync()) + .Where(g => wanted.Contains(g.Name)) + .ToDictionary(g => g.Name, g => g.Value, StringComparer.OrdinalIgnoreCase); + } + + public string? GetSecretValue(string name) { + return secureStorage?.Get(SecretKey(name)); + } + + public async Task SetSecretAsync(string name, string value) { + name = name.Trim(); + if (!ActionStepTypeHelper.IsValidName(name) || secureStorage == null) return false; + + await _globalsLock.WaitAsync(); + try { + await using AppDbContext db = await factory.CreateDbContextAsync(); + ActionGlobal? row = await db.ActionGlobals + .FirstOrDefaultAsync(g => g.Kind == ActionStoreKind.Secret && g.Name == name); + if (row == null) + db.ActionGlobals.Add(new ActionGlobal { + Kind = ActionStoreKind.Secret, Name = name, StorageKey = SecretKey(name) + }); + else row.UpdatedAt = DateTime.Now; + await db.SaveChangesAsync(); + if (!secureStorage.Set(SecretKey(name), value)) return false; + } + finally { + _globalsLock.Release(); + } + + NotifyGlobals(); + return true; + } + + public async Task SetGlobalAsync(string name, ActionValueType type, string? value) { + name = name.Trim(); + if (!ActionStepTypeHelper.IsValidName(name)) return "Names are letters, numbers, and _ only"; + + string? stored = string.IsNullOrEmpty(value) ? type.DefaultValue() : type.NormalizeValue(value); + if (stored == null && !string.IsNullOrEmpty(value)) return $"\"{value}\" isn't a {type.GetLabel()} value"; + + await _globalsLock.WaitAsync(); + try { + await using AppDbContext db = await factory.CreateDbContextAsync(); + ActionGlobal? row = await db.ActionGlobals + .FirstOrDefaultAsync(g => g.Kind == ActionStoreKind.Global && g.Name == name); + if (row == null) { + row = new ActionGlobal { Kind = ActionStoreKind.Global, Name = name }; + db.ActionGlobals.Add(row); + } + + row.ValueType = type; + row.Value = stored; + row.UpdatedAt = DateTime.Now; + await db.SaveChangesAsync(); + } + finally { + _globalsLock.Release(); + } + + NotifyGlobals(name); + return null; + } + + public async Task SetGlobalTypeAsync(string name, ActionValueType type) { + bool kept; + await _globalsLock.WaitAsync(); + try { + await using AppDbContext db = await factory.CreateDbContextAsync(); + ActionGlobal? row = await db.ActionGlobals + .FirstOrDefaultAsync(g => g.Kind == ActionStoreKind.Global && g.Name == name); + if (row == null) return false; + if (row.ValueType == type) return true; + + string? converted = ActionStepTypeHelper.ConvertValue(row.Value, row.ValueType, type); + kept = converted != null || string.IsNullOrEmpty(row.Value); + row.Value = converted ?? type.DefaultValue(); + row.ValueType = type; + row.UpdatedAt = DateTime.Now; + await db.SaveChangesAsync(); + } + finally { + _globalsLock.Release(); + } + + NotifyGlobals(name); + return kept; + } + + public async Task DeleteGlobalAsync(ActionStoreKind kind, string name) { + await using AppDbContext db = await factory.CreateDbContextAsync(); + await db.ActionGlobals.Where(g => g.Kind == kind && g.Name == name).ExecuteDeleteAsync(); + if (kind == ActionStoreKind.Secret) secureStorage?.Delete(SecretKey(name)); + if (kind == ActionStoreKind.Global) NotifyGlobals(name); + else NotifyGlobals(); + } + + private async Task RunSetGlobalAsync(ActionStep step, CancellationToken ct) { + (_, _, string? error) = await ChangeGlobalAsync(step.Target, null, step.Operation, step.Body ?? "", false, ct); + if (error == null) return true; + logger?.LogWarning("[Actions] {Error}", error); + return false; + } + + public async Task<(ActionGlobal? Global, bool Created, string? Error)> ChangeGlobalAsync(string name, + ActionValueType? type, ActionOperation operation, string? value, bool create, CancellationToken ct = default) { + name = name.Trim(); + if (!ActionStepTypeHelper.IsValidName(name)) + return (null, false, $"\"{name}\" isn't a valid global name (letters, numbers, and _ only)"); + + ActionGlobal? row; + bool created; + await _globalsLock.WaitAsync(ct); + try { + await using AppDbContext db = await factory.CreateDbContextAsync(ct); + row = (await db.ActionGlobals.Where(g => g.Kind == ActionStoreKind.Global).ToListAsync(ct)) + .FirstOrDefault(g => g.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + created = row == null; + + if (row == null && !create) return (null, false, $"There is no global named \"{name}\" to set"); + if (row != null && type != null && type != row.ValueType) + return (null, false, $"%global.{row.Name}% is {row.ValueType.GetLabel()}, not {type.Value.GetLabel()}"); + + ActionValueType? implied = operation switch { + ActionOperation.Adjust => ActionValueType.Number, + ActionOperation.Toggle => ActionValueType.Boolean, + _ => null + }; + if (row == null && (type ?? implied) == null) + return (null, false, $"A type is needed to make the global \"{name}\""); + + ActionValueType valueType = row?.ValueType ?? type ?? implied!.Value; + string current = valueType.NormalizeValue(row?.Value) ?? valueType.DefaultValue(); + string input = valueType == ActionValueType.Text ? value ?? "" : (value ?? "").Trim(); + string? next = operation switch { + ActionOperation.Toggle when valueType == ActionValueType.Boolean => + current == "true" ? "false" : "true", + ActionOperation.Adjust when valueType == ActionValueType.Number + && ActionValueType.Number.NormalizeValue(input) is { } amount => + ActionValueType.Number.NormalizeValue( + (double.Parse(current, CultureInfo.InvariantCulture) + + double.Parse(amount, CultureInfo.InvariantCulture)).ToString(CultureInfo.InvariantCulture)), + ActionOperation.Set when value != null => valueType.NormalizeValue(input), + _ => null + }; + + if (next == null) + return (null, false, operation is ActionOperation.Set or ActionOperation.Adjust && value == null + ? $"A value is needed to {operation.GetOpLabel().ToLowerInvariant()} %global.{row?.Name ?? name}%" + : $"Can't {operation.GetOpLabel().ToLowerInvariant()} %global.{row?.Name ?? name}% " + + $"({valueType.GetLabel()}) with \"{input}\""); + + if (row == null) { + row = new ActionGlobal { Kind = ActionStoreKind.Global, Name = name, ValueType = valueType }; + db.ActionGlobals.Add(row); + } + + row.Value = next; + row.UpdatedAt = DateTime.Now; + await db.SaveChangesAsync(ct); + } + finally { + _globalsLock.Release(); + } + + NotifyGlobals(row.Name); + return (row, created, null); + } + + public async Task EnsureGlobalsAsync(CustomAction action, bool reportConflicts = false) { + var used = new Dictionary<(ActionStoreKind, string), (ActionValueType Type, bool Strict)>( + new StoreKeyComparer()); + foreach (ActionNode node in action.Graph.Nodes) { + foreach ((ActionStoreKind kind, string name) in ActionStepTypeHelper.FindStoreRefs(node.Step.AllText)) + used.TryAdd((kind, name), (ActionValueType.Text, false)); + if (node.Step.Type != ActionStepType.SetGlobal || + !ActionStepTypeHelper.IsValidName(node.Step.Target.Trim())) + continue; + + used[(ActionStoreKind.Global, node.Step.Target.Trim())] = node.Step.Operation switch { + ActionOperation.Adjust => (ActionValueType.Number, true), + ActionOperation.Toggle => (ActionValueType.Boolean, true), + _ => (ActionValueType.Text, false) + }; + } + + return (await EnsureStoreAsync(used, $"Action \"{action.Name}\"", reportConflicts)).Added; + } + + public async Task> EnsureTypedGlobalsAsync( + IReadOnlyDictionary wanted, string source) { + Dictionary<(ActionStoreKind, string), (ActionValueType, bool)> used = wanted + .Where(w => ActionStepTypeHelper.IsValidName(w.Key.Trim())) + .DistinctBy(w => w.Key.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(w => (ActionStoreKind.Global, w.Key.Trim()), w => (w.Value, true), new StoreKeyComparer()); + return (await EnsureStoreAsync(used, source, true)).Conflicts; + } + + public event Action>? GlobalTypeConflictsFound; + + private async Task<(int Added, IReadOnlyList Conflicts)> EnsureStoreAsync( + Dictionary<(ActionStoreKind, string), (ActionValueType Type, bool Strict)> used, string source, + bool reportConflicts) { + if (used.Count == 0) return (0, []); + + List added; + List conflicts; + await _globalsLock.WaitAsync(); + try { + await using AppDbContext db = await factory.CreateDbContextAsync(); + Dictionary<(ActionStoreKind, string), ActionGlobal> known = (await db.ActionGlobals.AsNoTracking() + .ToListAsync()) + .ToDictionary(g => (g.Kind, g.Name), g => g, new StoreKeyComparer()); + + conflicts = used.Where(u => u.Value.Strict && known.TryGetValue(u.Key, out ActionGlobal? g) + && g.Kind == ActionStoreKind.Global + && g.ValueType != u.Value.Type) + .Select(u => new GlobalTypeConflict(known[u.Key].Name, known[u.Key].ValueType, u.Value.Type)) + .ToList(); + + added = used.Where(u => !known.ContainsKey(u.Key)) + .Select(u => new ActionGlobal { + Kind = u.Key.Item1, Name = u.Key.Item2, ValueType = u.Value.Type, + Value = u.Key.Item1 == ActionStoreKind.Global ? u.Value.Type.DefaultValue() : null, + StorageKey = u.Key.Item1 == ActionStoreKind.Secret ? SecretKey(u.Key.Item2) : null + }) + .ToList(); + if (added.Count > 0) { + db.ActionGlobals.AddRange(added); + await db.SaveChangesAsync(); + } + } + finally { + _globalsLock.Release(); + } + + if (added.Count > 0) { + logger?.LogInformation("[Actions] {Source} uses globals / secrets not set up yet: {Names}", source, + string.Join(", ", added.Select(g => ActionStepTypeHelper.StorePlaceholder(g.Kind, g.Name)))); + NotifyGlobals(added.Where(g => g.Kind == ActionStoreKind.Global).Select(g => g.Name).ToArray()); + } + + if (conflicts.Count > 0 && reportConflicts) { + logger?.LogWarning("[Actions] {Source} expects different types for: {Names}", source, + string.Join(", ", conflicts.Select(c => $"{c.Name} ({c.Existing} -> {c.Wanted})"))); + GlobalTypeConflictsFound?.Invoke(source, conflicts); + } + + return (added.Count, conflicts); + } + + public IReadOnlyList ActionsUsing(ActionStoreKind kind, string name) { + return CustomActions.Where(a => a.Graph.Nodes.Any(n => + ActionStepTypeHelper.FindStoreRefs(n.Step.AllText) + .Any(r => r.Kind == kind && r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + || (kind == ActionStoreKind.Global && n.Step.Type == ActionStepType.SetGlobal + && n.Step.Target.Trim().Equals(name, + StringComparison.OrdinalIgnoreCase)))) + .ToList(); + } + + public CustomAction? GetCustomAction(Guid id) { + return _library.TryGetValue(id, out (CustomAction Action, string Path) entry) ? entry.Action : null; + } + + public string? GetCustomActionPath(Guid id) { + return _library.TryGetValue(id, out (CustomAction Action, string Path) entry) ? entry.Path : null; + } + + public async Task LoadLibraryAsync() { + Directory.CreateDirectory(_folder); + _library.Clear(); + foreach (string file in Directory.EnumerateFiles(_folder, $"*{CustomAction.FileExtension}", + SearchOption.AllDirectories)) { + CustomAction? action = await ReadActionFileAsync(file); + if (action == null) continue; + if (!_library.TryAdd(action.Id, (action, file))) + logger?.LogWarning("[Actions] {File} has the same id as {Other}; ignoring it", file, + _library[action.Id].Path); + } + + _libraryLoaded = true; + logger?.LogInformation("[Actions] Loaded {Count} custom action(s) from {Folder}", _library.Count, _folder); + CustomActionsChanged?.Invoke(); + } + + public async Task SaveCustomActionAsync(CustomAction action, bool imported = false) { + string path = _library.TryGetValue(action.Id, out (CustomAction Action, string Path) existing) + ? existing.Path + : NewActionPath(_folder, action.Name); + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temp = $"{path}.tmp"; + await File.WriteAllTextAsync(temp, action.ToJson()); + File.Move(temp, path, true); + + _library[action.Id] = (action, path); + CustomActionsChanged?.Invoke(); + + try { + await EnsureGlobalsAsync(action, imported); + } + catch (Exception ex) { + logger?.LogWarning(ex, "[Actions] Could not add the globals and secrets \"{Name}\" uses", action.Name); + } + } + + public void DeleteCustomAction(Guid id) { + if (!_library.TryRemove(id, out (CustomAction Action, string Path) entry)) return; + try { + File.Delete(entry.Path); + } + catch (Exception ex) { + logger?.LogWarning(ex, "[Actions] Could not delete {File}", entry.Path); + } + + CustomActionsChanged?.Invoke(); + } + + public async Task<(CustomAction Action, bool Replaced)?> ImportCustomActionAsync(string file) { + CustomAction? action = await ReadActionFileAsync(file); + if (action == null) return null; + bool replaced = _library.ContainsKey(action.Id); + await SaveCustomActionAsync(action, true); + return (action, replaced); + } + + public async Task AddCustomActionCopyAsync(string file) { + if (!_libraryLoaded) await LoadLibraryAsync(); + CustomAction? action = await ReadActionFileAsync(file); + if (action == null) return null; + + if (_library.ContainsKey(action.Id)) action.Id = Guid.NewGuid(); + action.Name = UniqueName(action.Name); + await SaveCustomActionAsync(action, true); + return action; + } + + public async Task DuplicateCustomActionAsync(CustomAction original) { + CustomAction copy = original.Clone(); + copy.Id = Guid.NewGuid(); + copy.Name = UniqueName(original.Name); + await SaveCustomActionAsync(copy); + return copy; + } + + + [GeneratedRegex(@"\s\(Copy \d+\)$", RegexOptions.IgnoreCase)] + private static partial Regex CopySuffix(); + + private string UniqueName(string name) { + var taken = new HashSet(_library.Values.Select(e => e.Action.Name), StringComparer.OrdinalIgnoreCase); + if (!taken.Contains(name)) return name; + + string baseName = CopySuffix().Replace(name, ""); + var n = 1; + while (taken.Contains($"{baseName} (Copy {n})")) n++; + return $"{baseName} (Copy {n})"; + } + + public static async Task ExportCustomActionAsync(CustomAction action) { + Directory.CreateDirectory(ExportsFolder); + string path = Path.Combine(ExportsFolder, + $"{WidgetPackPaths.Slug(action.Name)}_{WidgetPackPaths.Slug(action.Version)}{CustomAction.FileExtension}"); + await File.WriteAllTextAsync(path, action.ToJson()); + return path; + } + + private async Task ReadActionFileAsync(string file) { + try { + if (CustomAction.TryParse(await File.ReadAllTextAsync(file), out CustomAction? action)) return action; + logger?.LogWarning("[Actions] {File} is not a valid custom action", file); + } + catch (Exception ex) { + logger?.LogWarning(ex, "[Actions] Could not read {File}", file); + } + + return null; + } + + private static string NewActionPath(string folder, string name) { + string slug = WidgetPackPaths.Slug(name); + if (string.IsNullOrEmpty(slug)) slug = "action"; + string path = Path.Combine(folder, $"{slug}{CustomAction.FileExtension}"); + for (var i = 2; File.Exists(path); i++) + path = Path.Combine(folder, $"{slug}-{i}{CustomAction.FileExtension}"); + return path; + } + + public ActionGraph? ResolveGraph(WheelSpinActionType type, string? parameter) { + if (!type.IsAvailable()) return null; + if (type != WheelSpinActionType.CustomAction) return type.BuildActionGraph(parameter); + return Guid.TryParse(parameter, out Guid id) && GetCustomAction(id) is { Disabled: false } action + ? action.Graph : null; + } + + public async Task RunAsync(Guid runId, ActionGraph graph, ActionContext ctx, + ActionRunProgress? progress = null, Func? onProgress = null) { + progress ??= new ActionRunProgress(); + + if (!graph.IsValid(out string invalid)) { + logger?.LogWarning("[Actions] Not running {Label}: {Error}", ctx.Label ?? ctx.RepeatKey, invalid); + return ActionRunResult.Paused; + } + + ActionRepeatMode mode = graph.EffectiveRepeat; + List overlapping = _runs.Values.Where(r => r.RepeatKey == ctx.RepeatKey && r.Id != runId).ToList(); + if (overlapping.Count > 0 && mode == ActionRepeatMode.Skip) return ActionRunResult.Skipped; + if (mode == ActionRepeatMode.Restart) + foreach (LiveRun previous in overlapping) { + previous.Superseded = true; + previous.Cancel(); + } + + // queue logic + Task ahead = Task.CompletedTask; + TaskCompletionSource? turn = null; + if (mode == ActionRepeatMode.Queue) { + turn = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + lock (_queueTails) { + ahead = _queueTails.GetValueOrDefault(ctx.RepeatKey) ?? Task.CompletedTask; + _queueTails[ctx.RepeatKey] = turn.Task; + } + } + + var live = new LiveRun(runId, ctx.RepeatKey); + try { + if (!_runs.TryAdd(runId, live)) return ActionRunResult.Skipped; + try { + await ahead.WaitAsync(live.Token); + } + catch (OperationCanceledException) { + return ActionRunResult.Cancelled; + } + + bool finished = await RunGraphAsync(graph, ctx, progress, onProgress, live.Token); + if (live.Superseded || finished) return ActionRunResult.Done; + return live.Cancelled ? ActionRunResult.Cancelled : ActionRunResult.Paused; + } + finally { + _runs.TryRemove(runId, out _); + live.Dispose(); + if (turn != null) { + void Release() { + turn.TrySetResult(); + lock (_queueTails) { + if (_queueTails.GetValueOrDefault(ctx.RepeatKey) == turn.Task) + _queueTails.Remove(ctx.RepeatKey); + } + } + + if (ahead.IsCompleted) Release(); + else _ = ahead.ContinueWith(_ => Release(), TaskScheduler.Default); + } + } + } + + private readonly Dictionary _queueTails = new(StringComparer.Ordinal); + + private async Task RunGraphAsync(ActionGraph graph, ActionContext ctx, ActionRunProgress progress, + Func? onProgress, CancellationToken outerCt) { + using var failCts = CancellationTokenSource.CreateLinkedTokenSource(outerCt); + CancellationToken ct = failCts.Token; + + Dictionary pending = graph.Nodes + .Where(n => !progress.IsResolved(n.Id)) + .ToDictionary(n => n.Id); + + var running = new Dictionary, ActionNode>(); + var inFlight = new Dictionary(StringComparer.Ordinal); + HashSet outputs = graph.OutputVariables(); + + if (graph.Nodes.Any(n => n.Step.Type == ActionStepType.Trigger)) outputs.Add(ActionRunProgress.TriggerVariable); + if (ctx.TriggerValues != null && !progress.TryReadVariable(ActionRunProgress.TriggerVariable, out _)) + progress.SetVariable(ActionRunProgress.TriggerVariable, JsonSerializer.Serialize(ctx.TriggerValues)); + + bool StartsRun(ActionNode node) { + return node.Step.Type == ActionStepType.Trigger + ? ctx.TriggerNodes?.Contains(node.Id) ?? false : ctx.Trigger == null; + } + + void StartReady() { + bool changed; + do { + changed = false; + foreach (ActionNode node in pending.Values.ToList()) { + List incoming = graph.IncomingEdges(node.Id).ToList(); + if (incoming.Count == 0) { + pending.Remove(node.Id); + if (StartsRun(node)) { + Start(node); + continue; + } + + progress.MarkSkipped(node.Id); + changed = true; + continue; + } + + int have = progress.Runs(node.Id) + inFlight.GetValueOrDefault(node.Id); + if (node.Inputs == ActionInputMode.Multiple) { + for (int handedOver = incoming.Sum(progress.Firings); have < handedOver; have++) + Start(node); + } + else { + while (incoming.Any(e => progress.Firings(e) > have) + && incoming.All(e => progress.Firings(e) > have || progress.IsResolved(e.From))) { + Start(node); + have++; + } + } + + if (inFlight.GetValueOrDefault(node.Id) > 0 || !incoming.All(e => progress.IsResolved(e.From))) + continue; + + pending.Remove(node.Id); + if (progress.Runs(node.Id) > 0) progress.MarkDone(node.Id); + else progress.MarkSkipped(node.Id); + changed = true; + } + } while (changed); + } + + void Start(ActionNode node) { + inFlight[node.Id] = inFlight.GetValueOrDefault(node.Id) + 1; + running[StartStep(node)] = node; + } + + async Task<(bool Ok, string? Port)> StartStep(ActionNode node) { + if (node.Disabled || node.Step.Type == ActionStepType.Trigger) { + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + return (true, null); + } + + if (node.Step.Type == ActionStepType.Condition) + return (true, await RunConditionAsync(node, ctx, progress, outputs, ct)); + return (await RunStepAsync(node.Step, ctx, progress, outputs, ct), null); + } + + StartReady(); + var failed = false; + while (running.Count > 0) { + Task<(bool Ok, string? Port)> next = await Task.WhenAny(running.Keys); + ActionNode node = running[next]; + running.Remove(next); + inFlight[node.Id]--; + + bool ok; + string? port = null; + try { + (ok, port) = await next; + } + catch (OperationCanceledException) { + ok = false; + } + catch (Exception ex) { + logger?.LogError(ex, "[Actions] Step {Step} threw", node.Step.Describe()); + ok = false; + } + + if (!ok && node.IgnoreErrors && !ct.IsCancellationRequested) { + logger?.LogInformation("[Actions] {Label}: \"{Step}\" failed. Ignore Errors = true", + ctx.Label ?? ctx.RepeatKey, node.Step.Describe()); + ok = true; + } + + if (ok) { + progress.RecordRun(node.Id, port); + progress.SetPort(node.Id, port); + if (!graph.IncomingEdges(node.Id).Any()) progress.MarkDone(node.Id); + + if (onProgress != null) await onProgress(progress); + if (!failed && !ct.IsCancellationRequested) StartReady(); + continue; + } + + if (failed || ct.IsCancellationRequested) continue; + failed = true; + logger?.LogWarning("[Actions] {Label} paused at \"{Step}\"", ctx.Label ?? ctx.RepeatKey, + node.Step.Describe()); + await failCts.CancelAsync(); + } + + return !failed && pending.Count == 0 && !outerCt.IsCancellationRequested; + } + + private async Task RunConditionAsync(ActionNode node, ActionContext ctx, ActionRunProgress progress, + IReadOnlySet outputs, CancellationToken ct) { + ct.ThrowIfCancellationRequested(); + ActionStep step = await FillVariablesAsync(node.Step, ctx, progress, outputs); + + bool result = ActionStepTypeHelper.Compare(step.Scope ?? "", step.Operation, step.Target); + + if (logger?.IsEnabled(LogLevel.Debug) ?? false) + logger.LogDebug("[Actions] {Label}: \"{Step}\" was {Result}", ctx.Label ?? ctx.RepeatKey, + node.Step.Describe(), result); + return result ? null : ActionEdge.ElsePort; + } + + private async Task RunStepAsync(ActionStep step, ActionContext ctx, ActionRunProgress progress, + IReadOnlySet outputs, CancellationToken ct) { + ct.ThrowIfCancellationRequested(); + if (!step.Type.IsAvailable()) return false; + + ActionStep original = step; + if (step.Type.AllowsVariables()) step = await FillVariablesAsync(step, ctx, progress, outputs); + + switch (step.Type) { + case ActionStepType.HttpGet: + case ActionStepType.HttpPost: + return await RunWebRequestAsync(step, original, progress, ct); + + case ActionStepType.SetGlobal: + return await RunSetGlobalAsync(step, ct); + + case ActionStepType.SetActionEnabled: + return await RunSetActionEnabledAsync(step); + + case ActionStepType.RunAction: + return await RunOtherActionAsync(step, ctx, ct); + + case ActionStepType.Wait: + await Task.Delay(step.Duration, ct); // when here, easier to do a delay vs the timerservice callback + return true; + + case ActionStepType.AddTime: + case ActionStepType.SubtractTime: { + SubathonCommandType cmd = step.Type == ActionStepType.AddTime + ? SubathonCommandType.AddTime : SubathonCommandType.SubtractTime; + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { + Source = ctx.Source, + EventTimestamp = DateTime.Now, + Command = cmd, + EventType = SubathonEventType.Command, + User = ctx.User, + Value = $"{cmd} {Utils.FormatShortDuration(step.Duration)}", + SecondsValue = step.Duration.TotalSeconds, + PointsValue = 0, + EventTypeMeta = FromActionMeta + }); + return true; + } + + case ActionStepType.SetMultiplier: { + if (_multiplierActive) { + logger?.LogInformation("[Actions] A multiplier is already running; holding \"{Step}\"", + step.Describe()); + return false; + } + + var amount = (step.Value ?? 1).ToString(CultureInfo.InvariantCulture); + string duration = step.Duration > TimeSpan.Zero ? $"{(int)step.Duration.TotalSeconds}s" : "xs"; + bool points = step.Operation is ActionOperation.Points or ActionOperation.PointsAndTime; + bool time = step.Operation is ActionOperation.Time or ActionOperation.PointsAndTime; + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { + Source = ctx.Source, + EventTimestamp = DateTime.Now, + Command = SubathonCommandType.SetMultiplier, + EventType = SubathonEventType.Command, + User = ctx.User, + Value = $"{amount}|{duration}|{points}|{time}", + EventTypeMeta = FromActionMeta + }); + _multiplierActive = true; + return true; + } + + case ActionStepType.Reroll: { + var count = (int)(step.Value ?? 0); + if (count < 1) return false; + int owed = await StateValueHelper.AddIntAsync(factory, StateKeys.WheelSpinsOwed, count); + WheelEvents.RaiseSpinsOwedUpdateFromEvent(owed); + return true; + } + + default: + if (_runners.TryGetValue(step.Type, out IActionStepRunner? runner)) + return await runner.RunStepAsync(step, ctx, progress, ct); + logger?.LogWarning("[Actions] No runner for step type {Type}", step.Type); + return false; + } + } + + private async Task RunWebRequestAsync(ActionStep step, ActionStep original, ActionRunProgress progress, + CancellationToken ct) { + string? output = string.IsNullOrEmpty(step.OutputVariable) ? null : step.OutputVariable; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(step.Duration > TimeSpan.Zero + ? step.Duration < MaxRequestTimeout ? step.Duration : MaxRequestTimeout + : DefaultRequestTimeout); + + try { + using var request = new HttpRequestMessage( + step.Type == ActionStepType.HttpGet ? HttpMethod.Get : HttpMethod.Post, step.Target.Trim()); + if (step.Type == ActionStepType.HttpPost) { + string body = step.Body ?? ""; + string start = body.TrimStart(); + string mediaType = start.StartsWith('{') || start.StartsWith('[') ? "application/json" : "text/plain"; + request.Content = new StringContent(body, Encoding.UTF8, mediaType); + } + + foreach ((string name, string value) in step.HeaderLines()) { + if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) { + if (request.Content != null && MediaTypeHeaderValue.TryParse(value, out MediaTypeHeaderValue? type)) + request.Content.Headers.ContentType = type; + continue; + } + + if (!request.Headers.TryAddWithoutValidation(name, value)) + request.Content?.Headers.TryAddWithoutValidation(name, value); + } + + request.Headers.Authorization = step.Auth switch { + ActionHttpAuth.Bearer => new AuthenticationHeaderValue("Bearer", step.AuthToken?.Trim()), + ActionHttpAuth.Basic => new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{step.AuthUser}:{step.AuthToken}"))), + _ => request.Headers.Authorization + }; + + using HttpResponseMessage response = + await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token); + if (output != null) { + progress.SetVariable(output, await response.Content.ReadAsStringAsync(timeout.Token)); + progress.SetVariable($"{output}{StatusSuffix}", $"{(int)response.StatusCode}"); + } + + if (response.IsSuccessStatusCode) return true; + logger?.LogWarning("[Actions] {Method} {Url} answered {Status}", request.Method, original.Target, + (int)response.StatusCode); + return false; + } + catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException or UriFormatException + || (ex is OperationCanceledException && !ct.IsCancellationRequested)) { + if (output != null) { + progress.SetVariable(output, ""); + progress.SetVariable($"{output}{StatusSuffix}", "0"); + } + + logger?.LogWarning("[Actions] {Type} {Url} failed: {Message}", original.Type.GetLabel(), original.Target, + ex is OperationCanceledException ? "timed out" : ex.Message); + return false; + } + } + + public async Task RunWheelSpinAsync(Guid historyId, Func? announce = null) { + WheelSpinHistory? history = await LoadHistoryAsync(historyId); + if (history == null) return ActionRunResult.Skipped; + + WheelSpinAction? action = history.LinkedItem?.Action; + ActionGraph? graph = action == null ? null : ResolveGraph(action.ActionType, action.Parameter); + if (action == null || graph == null || IsRunning(historyId)) { + if (action?.ActionType.HasAction() == true && graph == null) + logger?.LogWarning("[Actions] Wheel item \"{Item}\" has no runnable action ({Type}: \"{Parameter}\")", + history.LinkedItem?.Text, action.ActionType, action.Parameter); + if (announce != null) await announce(history.Status); + return ActionRunResult.Skipped; + } + + bool quiet = announce != null && graph.Nodes.All(n => n.Disabled || n.Step.Type != ActionStepType.Wait); + if (announce != null && !quiet) await announce(history.Status); + await SaveHistoryAsync(history, WheelSpinHistoryStatus.Running, history.ActionProgress, !quiet); + + var ctx = new ActionContext(SubathonEventSource.WheelSpin, "WheelSpin", + action.ActionType.RepeatKey(action.Parameter, action.WheelItemId), history.LinkedItem?.Text); + + if (action.ActionType == WheelSpinActionType.CustomAction + && Guid.TryParse(action.Parameter, out Guid customId) && GetCustomAction(customId) is { } custom) + LogRun(custom.Name, ctx); + ActionRunProgress progress = ActionRunProgress.Parse(history.ActionProgress); + ActionRunResult result = await RunAsync(historyId, graph, ctx, progress, + p => SaveProgressAsync(historyId, p)); + + bool keepStatus = result == ActionRunResult.Cancelled + || (result == ActionRunResult.Skipped && IsRunning(historyId)); + + history = await LoadHistoryAsync(historyId) ?? history; + if (!keepStatus && history.Status == WheelSpinHistoryStatus.Running) { + if (result == ActionRunResult.Paused) + await SaveHistoryAsync(history, WheelSpinHistoryStatus.Pending, progress.ToJson(), !quiet); + else + await SaveHistoryAsync(history, WheelSpinHistoryStatus.Done, null, !quiet); + } + + if (quiet) await announce!(history.Status); + return result; + } + + private async Task LoadHistoryAsync(Guid historyId) { + await using AppDbContext db = await factory.CreateDbContextAsync(); + return await db.WheelSpinHistories + .Include(h => h.LinkedItem).ThenInclude(i => i!.Action) + .Include(h => h.LinkedWheel) + .AsNoTracking() + .FirstOrDefaultAsync(h => h.Id == historyId); + } + + private async Task SaveProgressAsync(Guid historyId, ActionRunProgress progress) { + string json = progress.ToJson(); + await using AppDbContext db = await factory.CreateDbContextAsync(); + await db.WheelSpinHistories.Where(h => h.Id == historyId) + .ExecuteUpdateAsync(s => s.SetProperty(h => h.ActionProgress, json)); + } + + private async Task SaveHistoryAsync(WheelSpinHistory history, WheelSpinHistoryStatus status, string? progress, + bool raise) { + history.Status = status; + history.ActionProgress = progress; + history.UpdatedAt = DateTime.Now; + + await using AppDbContext db = await factory.CreateDbContextAsync(); + await db.WheelSpinHistories.Where(h => h.Id == history.Id) + .ExecuteUpdateAsync(s => s + .SetProperty(h => h.Status, status) + .SetProperty(h => h.ActionProgress, progress) + .SetProperty(h => h.UpdatedAt, history.UpdatedAt)); + + if (raise) + WheelEvents.RaiseWheelSpinStatusChanged(history, StateValueHelper.Get(db, StateKeys.WheelSpinsOwed)); + } + + ////////////////// prompts + private void OnPromptRunUpdate(SubathonPromptRun run, SubathonPrompt? prompt) { + if (run is { Status: SubathonPromptRunStatus.Completed, ActionId: not null }) + _ = RunPromptActionAsync(run.Id); + } + + public async Task RunPromptActionAsync(Guid runId, bool retry = false) { + SubathonPromptRun? run; + try { + await using AppDbContext db = await factory.CreateDbContextAsync(); + run = await db.SubathonPromptRuns.Include(r => r.LinkedPrompt).AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == runId); + if (run is not { Status: SubathonPromptRunStatus.Completed, ActionId: not null }) + return ActionRunResult.Skipped; + + SubathonPromptActionStatus from = retry ? SubathonPromptActionStatus.Failed : SubathonPromptActionStatus.None; + int claimed = await db.SubathonPromptRuns.Where(r => r.Id == runId && r.ActionStatus == from) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.ActionStatus, SubathonPromptActionStatus.Running)); + if (claimed == 0) return ActionRunResult.Skipped; + } + catch (Exception ex) { + logger?.LogError(ex, "[Actions] Could not start the action for prompt run {Id}", runId); + return ActionRunResult.Skipped; + } + + run.ActionStatus = SubathonPromptActionStatus.Running; + SubathonEvents.RaisePromptRunActionStatusChanged(run); + + CustomAction? action = GetCustomAction(run.ActionId.Value); + ActionGraph? graph = ResolveGraph(WheelSpinActionType.CustomAction, run.ActionId.Value.ToString()); + if (action == null || graph == null) { + logger?.LogWarning("[Actions] Prompt \"{Prompt}\" completed, but action {Reason}", + run.LinkedPrompt?.Text, action == null ? "is missing from the actions library" : "is disabled"); + await SavePromptActionAsync(run, SubathonPromptActionStatus.Failed, run.ActionProgress); + return ActionRunResult.Skipped; + } + + var ctx = new ActionContext(SubathonEventSource.Unknown, "Prompt", $"custom-action-{action.Id}", + run.LinkedPrompt?.Text ?? action.Name); + LogRun(action.Name, ctx); + + ActionRunProgress progress = ActionRunProgress.Parse(run.ActionProgress); + ActionRunResult result; + try { + result = await RunAsync(runId, graph, ctx, progress, async p => { + string json = p.ToJson(); + await using AppDbContext db = await factory.CreateDbContextAsync(); + await db.SubathonPromptRuns.Where(r => r.Id == runId) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.ActionProgress, json)); + }); + } + catch (Exception ex) { + logger?.LogError(ex, "[Actions] \"{Action}\" failed for prompt \"{Prompt}\"", action.Name, + run.LinkedPrompt?.Text); + result = ActionRunResult.Paused; + } + + bool done = result == ActionRunResult.Done; + await SavePromptActionAsync(run, done ? SubathonPromptActionStatus.Done : SubathonPromptActionStatus.Failed, + done ? null : progress.ToJson()); + return result; + } + + private async Task SavePromptActionAsync(SubathonPromptRun run, SubathonPromptActionStatus status, + string? progress) { + run.ActionStatus = status; + run.ActionProgress = progress; + try { + await using AppDbContext db = await factory.CreateDbContextAsync(); + await db.SubathonPromptRuns.Where(r => r.Id == run.Id) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.ActionStatus, status) + .SetProperty(r => r.ActionProgress, progress)); + } + catch (Exception ex) { + logger?.LogError(ex, "[Actions] Could not save action status for prompt run {Id}", run.Id); + } + + SubathonEvents.RaisePromptRunActionStatusChanged(run); + } + + private void OnWheelSpinStatusChanged(WheelSpinHistory history, int _) { + if (history.Status != WheelSpinHistoryStatus.Running) Cancel(history.Id); + } + + private void OnSubathonDataUpdate(SubathonData data, DateTime _) { + _multiplierActive = data.Multiplier?.IsRunning() ?? false; + } + + private sealed class StoreKeyComparer : IEqualityComparer<(ActionStoreKind Kind, string Name)> { + public bool Equals((ActionStoreKind Kind, string Name) x, (ActionStoreKind Kind, string Name) y) { + return x.Kind == y.Kind && string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode((ActionStoreKind Kind, string Name) key) { + return HashCode.Combine(key.Kind, StringComparer.OrdinalIgnoreCase.GetHashCode(key.Name)); + } + } + + private sealed class LiveRun(Guid id, string repeatKey) : IDisposable { + private readonly CancellationTokenSource _cts = new(); + public Guid Id { get; } = id; + public string RepeatKey { get; } = repeatKey; + public CancellationToken Token => _cts.Token; + public bool Cancelled { get; private set; } + public bool Superseded { get; set; } + + public void Dispose() { + _cts.Dispose(); + } + + public void Cancel() { + Cancelled = true; + try { + _cts.Cancel(); + } + catch (ObjectDisposedException) { + /* */ + } + } + } +} \ No newline at end of file diff --git a/SubathonManager.Services/CommandService.cs b/SubathonManager.Services/CommandService.cs index 30743bde..fe443835 100644 --- a/SubathonManager.Services/CommandService.cs +++ b/SubathonManager.Services/CommandService.cs @@ -105,6 +105,17 @@ private static bool ValidateParameters(SubathonEvent subathonEvent, string messa var isValid = false; switch (subathonEvent.Command) { + case SubathonCommandType.RunAction: { + string name = string.Join(' ', parts[1..]).Trim(); + if (name.Length == 0) break; + + ActionService? actions = AppServices.Provider?.GetService(); + if (actions != null && actions.FindCustomAction(name) == null) break; + + subathonEvent.Value = $"{subathonEvent.Command} {name}"; + isValid = true; + break; + } case SubathonCommandType.AddMoney: case SubathonCommandType.SubtractMoney: if (parts.Length >= 3 && Utils.TryParseAmount(parts[1], out double value)) { diff --git a/SubathonManager.Services/DiscordWebhookService.cs b/SubathonManager.Services/DiscordWebhookService.cs index a8be49fd..5797ba86 100644 --- a/SubathonManager.Services/DiscordWebhookService.cs +++ b/SubathonManager.Services/DiscordWebhookService.cs @@ -479,7 +479,8 @@ private void OnWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory } private void OnWheelSpinStatusChanged(WheelSpinHistory history, int spinsOwed) { - if (string.IsNullOrEmpty(_wheelWebhookUrl) || !_doWheelSpinEvents) return; + if (string.IsNullOrEmpty(_wheelWebhookUrl) || !_doWheelSpinEvents + || history.Status == WheelSpinHistoryStatus.Running) return; _wheelQueue.Enqueue(new WheelLogEntry(history.LinkedWheel?.Name ?? "Unknown", history.LinkedItem, history, spinsOwed, true)); } diff --git a/SubathonManager.Services/EventService.cs b/SubathonManager.Services/EventService.cs index bb9d5b9c..08247765 100644 --- a/SubathonManager.Services/EventService.cs +++ b/SubathonManager.Services/EventService.cs @@ -216,8 +216,10 @@ private async Task LoopAsync() { } else if (!string.IsNullOrEmpty(ev.Currency) && _currencyService.IsValidCurrency(ev.Currency) && (ev.EventType.IsCurrencyDonation() || - ev.EventType - .IsOrder())) // includes orders when parsed as money mode + ev.EventType.IsOrder() || + ev.EventType == SubathonEventType.PatreonPledge)) + // includes orders when parsed as money mode, and patreon pledges valued by amount + // currency would be "member" if it was not added in currency mode { double rate = Task.Run(() => _currencyService.ConvertAsync(Utils.ParseAmount(ev.Value), ev.Currency)).Result; @@ -436,6 +438,9 @@ await db.Database.ExecuteSqlRawAsync( case SubathonCommandType.SpinWheel: WheelEvents.RaiseWheelSpinRequested(); break; + case SubathonCommandType.RunAction: + ActionEvents.RaiseCustomActionRunRequested(ev); + break; case SubathonCommandType.AddMoney: ev.EventType = SubathonEventType.DonationAdjustment; break; @@ -577,7 +582,7 @@ private static async Task CheckForGoalChange(AppDbContext db, long newPoints, lo if (prevCompletedGoal2 != null && prevCompletedGoal1?.Id != prevCompletedGoal2.Id) { if (prevCompletedGoal1 == null || prevCompletedGoal1.Points <= prevCompletedGoal2.Points) - SubathonEvents.RaiseSubathonGoalCompleted(prevCompletedGoal2, newPoints); + RaiseGoalsCompletedBetween(goalSet, initialPoints, newPoints); else SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, newPoints, (GoalsType)goalSet.Type!); } @@ -590,6 +595,13 @@ private static async Task CheckForGoalChange(AppDbContext db, long newPoints, lo await Task.CompletedTask; } + private static void RaiseGoalsCompletedBetween(SubathonGoalSet goalSet, long fromPoints, long toPoints) { + foreach (SubathonGoal goal in goalSet.Goals + .Where(g => g.Points > fromPoints && g.Points <= toPoints) + .OrderBy(g => g.Points)) + SubathonEvents.RaiseSubathonGoalCompleted(goal, toPoints); + } + private void OnSubathonEventCancelled(Guid id, SubathonEventType type, string reference) { Task.Run(async () => { try { @@ -838,7 +850,7 @@ private async Task DetectGoalStateChange(AppDbContext db, SubathonData subathon, prevCompletedGoal1.Id != prevCompletedGoal2.Id) { // the else is for if we undid stuff if (prevCompletedGoal1.Points <= prevCompletedGoal2.Points) - SubathonEvents.RaiseSubathonGoalCompleted(prevCompletedGoal2, pts); + RaiseGoalsCompletedBetween(goalSet, initialPoints, pts); else SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, pts, (GoalsType)goalSet.Type!); } diff --git a/SubathonManager.Services/PromptOrchestratorService.cs b/SubathonManager.Services/PromptOrchestratorService.cs index 96ca3346..9bde029f 100644 --- a/SubathonManager.Services/PromptOrchestratorService.cs +++ b/SubathonManager.Services/PromptOrchestratorService.cs @@ -180,7 +180,8 @@ private async Task StartRunAsync(AppDbContext db, SubathonPromptSet set, Subatho ExpiresAt = DateTime.Now + prompt.CompletionDuration, Status = SubathonPromptRunStatus.Active, SnapshotTargetValue = prompt.Value, - BaselineCount = baseline + BaselineCount = baseline, + ActionId = prompt.CustomActionId }; db.SubathonPromptRuns.Add(run); diff --git a/SubathonManager.Tests/CoreUnitTests/CsvUtilsTests.cs b/SubathonManager.Tests/CoreUnitTests/CsvUtilsTests.cs new file mode 100644 index 00000000..6b54f163 --- /dev/null +++ b/SubathonManager.Tests/CoreUnitTests/CsvUtilsTests.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using SubathonManager.Core.Enums; + +namespace SubathonManager.Tests.CoreUnitTests; + +public class CsvUtilsTests { + [Fact] + public void Escape_QuotesOnlyWhenNeeded() { + Assert.Equal("Test", CsvUtils.Escape("Test")); + Assert.Equal(string.Empty, CsvUtils.Escape(string.Empty)); + Assert.Equal(string.Empty, CsvUtils.Escape(" ")); + Assert.Equal(string.Empty, CsvUtils.Escape(null)); + Assert.Equal("\"Test1,Test2\"", CsvUtils.Escape("Test1,Test2")); + Assert.Equal("\"\"\"Test1\"\"\"", CsvUtils.Escape("\"Test1\"")); + Assert.Equal("\"Test\r\nTest2\"", CsvUtils.Escape("Test\r\nTest2")); + } + + [Fact] + public void Escape_FormatsValuesInvariantRegardlessOfCulture() { + CultureInfo original = CultureInfo.CurrentCulture; + try { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.Equal("1.5", CsvUtils.Escape(1.5)); + Assert.Equal("1234.25", CsvUtils.Escape(1234.25m)); + Assert.Equal("2026-10-03 14:05:09", CsvUtils.Escape(new DateTime(2026, 10, 3, 14, 5, 9))); + Assert.Equal("True", CsvUtils.Escape(true)); + Assert.Equal(nameof(ScheduleItemKind.Event), CsvUtils.Escape(ScheduleItemKind.Event)); + Assert.Equal(string.Empty, CsvUtils.Escape((int?)null)); + } + finally { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void Write_EmitsHeaderRowsThenProjectedRows() { + string csv = CsvUtils.Write( + [["#Meta=1", "Other=2"], [], ["Name", "Value"]], + new[] { ("a,b", 1), ("plain", 2) }, + r => [r.Item1, r.Item2]); + + Assert.Equal(["#Meta=1,Other=2", "", "Name,Value", "\"a,b\",1", "plain,2"], + csv.TrimEnd().Split(Environment.NewLine)); + } + + [Fact] + public void ReadRecords_HandlesQuotedCommasQuotesAndNewlines() { + const string csv = "Name,Note\r\n\"a,b\",\"line one\nline \"\"two\"\"\"\r\n\r\nlast,\n"; + + List<(int Line, string[] Fields)> records = CsvUtils.ReadRecords(csv); + + Assert.Equal(3, records.Count); + Assert.Equal(["Name", "Note"], records[0].Fields); + Assert.Equal(["a,b", "line one\nline \"two\""], records[1].Fields); + Assert.Equal(2, records[1].Line); + Assert.Equal(["last", ""], records[2].Fields); + Assert.Equal(5, records[2].Line); + } + + [Fact] + public void ReadRecords_StripsBom() { + List<(int Line, string[] Fields)> records = CsvUtils.ReadRecords((char)0xFEFF + "A,B"); + Assert.Equal(["A", "B"], records.Single().Fields); + } + + [Fact] + public async Task ExportAsync_ThenReadFileAsync_RoundTrips() { + string path = await CsvUtils.ExportAsync("csv utils test: round/trip", + [["Text", "Value"]], + [("needs, quoting", 1.5),("multi\nline", 2.0)], + r => [r.Item1, r.Item2]); + try { + Assert.Equal(CsvUtils.ExportFolder, Path.GetDirectoryName(path)); + Assert.StartsWith("csv utils test_ round_trip-", Path.GetFileName(path)); + + List? rows = await CsvUtils.ReadFileAsync(path); + Assert.NotNull(rows); + Assert.Equal(3, rows.Count); + Assert.Equal(["needs, quoting", "1.5"], rows[1]); + Assert.Equal(["multi\nline", "2"], rows[2]); + } + finally { + File.Delete(path); + } + } + + [Fact] + public async Task ReadFileAsync_ReturnsNullForMissingFile() { + Assert.Null(await CsvUtils.ReadFileAsync(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".csv"))); + } +} diff --git a/SubathonManager.Tests/CoreUnitTests/UtilsTests.cs b/SubathonManager.Tests/CoreUnitTests/UtilsTests.cs index 8790d791..ccaf06ee 100644 --- a/SubathonManager.Tests/CoreUnitTests/UtilsTests.cs +++ b/SubathonManager.Tests/CoreUnitTests/UtilsTests.cs @@ -124,13 +124,67 @@ public void ParseCurrency() { Assert.Equal("CHF", Utils.TryParseCurrency("₣")); } + [Theory] + [InlineData("fr-CA")] + [InlineData("de-DE")] + [InlineData("en-US")] + public void TryParseAmount_AcceptsDotRegardlessOfCulture(string culture) { + System.Globalization.CultureInfo previous = System.Globalization.CultureInfo.CurrentCulture; + try { + System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo(culture); + + Assert.True(Utils.TryParseAmount("10.00", out double tip)); + Assert.Equal(10, tip); + Assert.True(Utils.TryParseAmount(" 0.5 ", out double half)); + Assert.Equal(0.5, half); + Assert.False(Utils.TryParseAmount("abc", out _)); + Assert.False(Utils.TryParseAmount(null, out _)); + + // making sure js will still be fine + var jsVar = new Core.Models.JsVariable { + Name = "speed", Value = "0.5", Type = Core.Enums.WidgetVariableType.Float + }; + Assert.Contains("= 0.5", jsVar.GetInjectLine()); + } + finally { + System.Globalization.CultureInfo.CurrentCulture = previous; + } + } + + [Theory] + [InlineData("fr-CA")] + [InlineData("de-DE")] + [InlineData("en-US")] + public void TryParseAmount_TreatsTrailingCommaDigitsAsDecimal(string culture) { + System.Globalization.CultureInfo previous = System.Globalization.CultureInfo.CurrentCulture; + try { + System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo(culture); + + Assert.True(Utils.TryParseAmount("0,5", out double half)); + Assert.Equal(0.5, half); + Assert.True(Utils.TryParseAmount("12,50", out double tip)); + Assert.Equal(12.5, tip); + Assert.True(Utils.TryParseAmount("-3,25", out double negative)); + Assert.Equal(-3.25, negative); + } + finally { + System.Globalization.CultureInfo.CurrentCulture = previous; + } + } + [Fact] - public void EscapeCsvData() { - Assert.Equal("Test", Utils.EscapeCsv("Test")); - Assert.Equal(string.Empty, Utils.EscapeCsv(string.Empty)); - Assert.Equal(string.Empty, Utils.EscapeCsv(null)); - Assert.Equal("\"Test1,Test2\"", Utils.EscapeCsv("Test1,Test2")); - Assert.Equal("\"\"\"Test1\"\"\"", Utils.EscapeCsv("\"Test1\"")); - Assert.Equal("\"Test\r\nTest2\"", Utils.EscapeCsv("Test\r\nTest2")); + public void TryParseAmount_KeepsThousandsCommaInDotCulture() { + System.Globalization.CultureInfo previous = System.Globalization.CultureInfo.CurrentCulture; + try { + System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo("en-US"); + + Assert.True(Utils.TryParseAmount("1,250", out double thousands)); + Assert.Equal(1250, thousands); + Assert.True(Utils.TryParseAmount("1,250.00", out double withCents)); + Assert.Equal(1250, withCents); + } + finally { + System.Globalization.CultureInfo.CurrentCulture = previous; + } } -} \ No newline at end of file +} diff --git a/SubathonManager.Tests/CoreUnitTests/WheelSpinVtsActionTypeTests.cs b/SubathonManager.Tests/CoreUnitTests/WheelSpinVtsActionTypeTests.cs index c469c5b8..bbb69f81 100644 --- a/SubathonManager.Tests/CoreUnitTests/WheelSpinVtsActionTypeTests.cs +++ b/SubathonManager.Tests/CoreUnitTests/WheelSpinVtsActionTypeTests.cs @@ -15,8 +15,9 @@ public void VTubeStudio_IsNotACommand() { } [Fact] - public void VTubeStudio_IsNotDoneImmediately() { - Assert.False(WheelSpinActionType.VTubeStudio.IsDoneImmediately()); + public void VTubeStudio_RunsAsSoonAsTheSpinLands() { + Assert.True(WheelSpinActionType.VTubeStudio.IsAutoRun()); + Assert.False(WheelSpinActionType.SetMultiplier.IsAutoRun()); } [Fact] diff --git a/SubathonManager.Tests/DataUnitTests/DbContextTests.cs b/SubathonManager.Tests/DataUnitTests/DbContextTests.cs index ed8b81bf..11a74eef 100644 --- a/SubathonManager.Tests/DataUnitTests/DbContextTests.cs +++ b/SubathonManager.Tests/DataUnitTests/DbContextTests.cs @@ -125,11 +125,12 @@ public async Task ActiveEventsToCsv_Writes_File() { await db.SaveChangesAsync(TestContext.Current.CancellationToken); - await AppDbContext.ActiveEventsToCsv(db); + string? path = await AppDbContext.ActiveEventsToCsv(db); - string[] files = Directory.GetFiles(Path.Combine(Config.DataFolder, "exports")); - Assert.Single(files); - File.Delete(files[0]); + Assert.NotNull(path); + Assert.True(File.Exists(path)); + Assert.Contains("KoFiDonation", await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken)); + File.Delete(path); } [Fact] diff --git a/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs b/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs index f3981465..39bbd24e 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs @@ -102,16 +102,20 @@ private static FourthwallOrderPlacedWebhookEvent MakeOrderEvent( UnitCost = new Money { Value = unitCost, Currency = currency } }; var offer = new OfferOrderV1 { Variant = variant }; + + // don't necessarily care about samples orders, but is useful for testing. + // user can delete if they import it or listen for it anyways + var data = new OrderV1 { Id = id ?? Guid.NewGuid().ToString(), Username = username, Message = "test", CreatedAt = DateTimeOffset.UtcNow, Status = OrderV1_status.CONFIRMED, - Source = new OrderV1.OrderV1_source { - Order = new Order { Type = orderType } - }, - Offers = new List { offer }, + Source = orderType == "SAMPLES_ORDER" + ? new OrderV1.OrderV1_source { SamplesOrder = new SamplesOrder { Type = orderType } } + : new OrderV1.OrderV1_source { Order = new Order { Type = orderType } }, + Offers = [offer], Amounts = new OrderAmounts { Subtotal = new Money { Value = subtotal, Currency = currency }, Total = new Money { Value = subtotal * 1.10, Currency = currency }, diff --git a/SubathonManager.Tests/IntegrationUnitTests/MakeShipServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/MakeShipServiceTests.cs index 3a7bb879..6d117118 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/MakeShipServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/MakeShipServiceTests.cs @@ -250,6 +250,45 @@ public async Task Campaign_NoSalesQuantity_StaysResolvedButPollFailing() { Assert.Equal(MakeShipProductType.Campaign, tracking.ProductType); Assert.Equal(CampaignProductId, tracking.ShopifyProductId); Assert.True(tracking.PollFailing); + Assert.False(tracking.Blocked); + } + + [Fact] + public async Task Campaign_Forbidden_MarksBlockedAndClearsOnRecovery() { + var tracking = new MakeShipTracking { Url = CampaignUrl }; + MakeShipTrackingRegistry.Upsert(tracking); + var handler = new RoutedHttpHandler { + [CampaignUrl] = (HttpStatusCode.OK, CampaignHtml), + [$"https://storefront.makeship.com/products/{CampaignProductId}/sales-quantity"] = + (HttpStatusCode.Forbidden, null) + }; + MakeShipService service = MakeService(handler); + + await service.PollTrackingAsync(tracking, CancellationToken.None); + + Assert.True(tracking.Blocked); + Assert.True(tracking.PollFailing); + + await MakeServiceReusingSync(service, CampaignHandler(800, 42)) + .PollTrackingAsync(tracking, CancellationToken.None); + + Assert.False(tracking.Blocked); + Assert.False(tracking.PollFailing); + Assert.Equal(800, tracking.Sales); + } + + [Fact] + public async Task Petition_ForbiddenOnPageResolve_MarksBlocked() { + var tracking = new MakeShipTracking { Url = PetitionUrl }; + MakeShipTrackingRegistry.Upsert(tracking); + MakeShipService service = MakeService(new RoutedHttpHandler { + [PetitionUrl] = (HttpStatusCode.Forbidden, null) + }); + + await service.PollTrackingAsync(tracking, CancellationToken.None); + + Assert.True(tracking.Blocked); + Assert.True(string.IsNullOrEmpty(tracking.ShopifyProductId)); } [Fact] diff --git a/SubathonManager.Tests/IntegrationUnitTests/MixItUpServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/MixItUpServiceTests.cs index 72322334..8309e829 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/MixItUpServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/MixItUpServiceTests.cs @@ -44,7 +44,7 @@ private static MixItUpService MakeService(RecordingHandler? handler = null, MockConfig.MakeMockConfig(config), factory.Object, timerService ?? new Mock().Object); } - private static Dictionary<(string, string), string> EnabledConfig(MixItUpTrigger trigger) { + private static Dictionary<(string, string), string> EnabledConfig(SubathonTrigger trigger) { return new Dictionary<(string, string), string> { [(MixItUpService.ConfigSection, "Enabled")] = "True", [(MixItUpService.ConfigSection, MixItUpService.CommandConfigKey(trigger))] = CommandId.ToString() @@ -240,29 +240,29 @@ public void ParseVersion_HandlesJsonAndRaw(string body, string? expected) { [Fact] public void Fire_Disabled_DoesNothing() { - Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.GoalCompleted); + Dictionary<(string, string), string> config = EnabledConfig(SubathonTrigger.GoalCompleted); config[(MixItUpService.ConfigSection, "Enabled")] = "False"; MixItUpService service = MakeService(config: config); - Assert.False(service.Fire(MixItUpTrigger.GoalCompleted, new Dictionary())); + Assert.False(service.Fire(SubathonTrigger.GoalCompleted, new Dictionary())); } [Fact] public void Fire_InvalidCommandId_DoesNothing() { - Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.GoalCompleted); - config[(MixItUpService.ConfigSection, MixItUpService.CommandConfigKey(MixItUpTrigger.GoalCompleted))] = + Dictionary<(string, string), string> config = EnabledConfig(SubathonTrigger.GoalCompleted); + config[(MixItUpService.ConfigSection, MixItUpService.CommandConfigKey(SubathonTrigger.GoalCompleted))] = "not-a-guid"; MixItUpService service = MakeService(config: config); - Assert.False(service.Fire(MixItUpTrigger.GoalCompleted, new Dictionary())); + Assert.False(service.Fire(SubathonTrigger.GoalCompleted, new Dictionary())); } [Fact] public async Task Fire_PostsCommandWithSpecialIdentifiers() { var handler = new RecordingHandler(HttpStatusCode.Accepted); - MixItUpService service = MakeService(handler, EnabledConfig(MixItUpTrigger.GoalCompleted)); + MixItUpService service = MakeService(handler, EnabledConfig(SubathonTrigger.GoalCompleted)); - bool fired = service.Fire(MixItUpTrigger.GoalCompleted, + bool fired = service.Fire(SubathonTrigger.GoalCompleted, new Dictionary { [Id("goaltext")] = "Hat on" }); RecordedRequest request = await handler.WaitForRequestAsync(TestContext.Current.CancellationToken); @@ -280,7 +280,7 @@ public async Task Fire_PostsCommandWithSpecialIdentifiers() { [Fact] public async Task DataUpdate_FiresOnPauseChangeOnly() { var handler = new RecordingHandler(HttpStatusCode.Accepted); - Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.TimerPaused); + Dictionary<(string, string), string> config = EnabledConfig(SubathonTrigger.TimerPaused); MixItUpService service = MakeService(handler, config); await service.StartAsync(TestContext.Current.CancellationToken); try { @@ -306,7 +306,7 @@ public async Task DataUpdate_FiresOnPauseChangeOnly() { [Fact] public async Task PromptEnded_FiresOnlyForEndedRuns() { var handler = new RecordingHandler(HttpStatusCode.Accepted); - MixItUpService service = MakeService(handler, EnabledConfig(MixItUpTrigger.PromptEnded)); + MixItUpService service = MakeService(handler, EnabledConfig(SubathonTrigger.PromptEnded)); await service.StartAsync(TestContext.Current.CancellationToken); try { var run = new SubathonPromptRun { Status = SubathonPromptRunStatus.Active }; @@ -334,7 +334,7 @@ public async Task PromptEnded_FiresOnlyForEndedRuns() { public async Task EventProcessed_CommandsOnlyWhenIncluded(SubathonEventType type, bool includeCommands, bool expectSent) { var handler = new RecordingHandler(HttpStatusCode.Accepted); - Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.SubathonEvent); + Dictionary<(string, string), string> config = EnabledConfig(SubathonTrigger.SubathonEvent); config[(MixItUpService.ConfigSection, MixItUpService.IncludeCommandsKey)] = includeCommands.ToString(); MixItUpService service = MakeService(handler, config); await service.StartAsync(TestContext.Current.CancellationToken); @@ -376,8 +376,8 @@ private static SubathonData WithMultiplier(Guid id, double multiplier, bool time [Fact] public async Task DataUpdate_FiresMultiplierStartAndEnd() { var handler = new RecordingHandler(HttpStatusCode.Accepted); - Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.MultiplierStarted); - config[(MixItUpService.ConfigSection, MixItUpService.CommandConfigKey(MixItUpTrigger.MultiplierEnded))] = + Dictionary<(string, string), string> config = EnabledConfig(SubathonTrigger.MultiplierStarted); + config[(MixItUpService.ConfigSection, MixItUpService.CommandConfigKey(SubathonTrigger.MultiplierEnded))] = CommandId.ToString(); MixItUpService service = MakeService(handler, config); await service.StartAsync(TestContext.Current.CancellationToken); @@ -417,7 +417,7 @@ await handler.WaitForRequestAsync(TestContext.Current.CancellationToken, [InlineData(false, true)] public void MultiplierIdentifiers_SendTimeAndPointsFlags(bool time, bool points) { Dictionary ids = MixItUpService.MultiplierIdentifiers( - new MixItUpService.MultiplierSnapshot(true, 1.5, time, points, null, null, false)); + new MultiplierSnapshot(true, 1.5, time, points, null, null, false)); Assert.Equal(time.ToString(), ids[Id("multipliertime")]); Assert.Equal(points.ToString(), ids[Id("multiplierpoints")]); @@ -427,7 +427,7 @@ public void MultiplierIdentifiers_SendTimeAndPointsFlags(bool time, bool points) [Fact] public void SampleIdentifiers_CoverEveryTrigger() { - foreach (MixItUpTrigger trigger in Enum.GetValues()) { + foreach (SubathonTrigger trigger in Enum.GetValues()) { IReadOnlyList names = MixItUpService.IdentifierNames(trigger); Assert.Contains($"${Id("trigger")}", names); Assert.True(names.Count > 1, $"{trigger} has no identifiers"); diff --git a/SubathonManager.Tests/IntegrationUnitTests/PatreonServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/PatreonServiceTests.cs new file mode 100644 index 00000000..60f5cf37 --- /dev/null +++ b/SubathonManager.Tests/IntegrationUnitTests/PatreonServiceTests.cs @@ -0,0 +1,314 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using DevTunnels.Client; +using Microsoft.Extensions.Logging; +using Moq; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Security; +using SubathonManager.Integration; +using SubathonManager.Services; +using SubathonManager.Tests.Utility; + +// ReSharper disable NullableWarningSuppressionIsUsed +namespace SubathonManager.Tests.IntegrationUnitTests; + +[Collection("GlobalState")] +public class PatreonServiceTests { + private const string Secret = "patreon-webhook-secret"; + + public PatreonServiceTests() { + typeof(IntegrationEvents) + .GetField("ConnectionUpdated", BindingFlags.Static | BindingFlags.NonPublic) + ?.SetValue(null, null); + } + + private static PatreonService MakeService(bool withSecret = true, bool byAmount = false) { + var storage = new InMemorySecureStorage(withSecret + ? new Dictionary { [StorageKeys.PatreonWebhookSecret] = Secret } + : null); + IConfig dtConfig = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { + { ("Server", "Port"), "14040" } + }); + var devTunnels = new DevTunnelsService(new Mock>().Object, dtConfig, + new Mock().Object); + var oAuth = new OAuthService(null, new Mock().Object, storage) { OpenBrowser = _ => { } }; + IConfig config = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { + { ("Patreon", $"{SubathonEventType.PatreonPledge}.ValueByAmount"), byAmount ? "True" : "False" } + }); + return new PatreonService(null, config, new Mock().Object, devTunnels, storage, oAuth); + } + + private static string Payload(int willPay = 500, int entitled = 500, int cadence = 1, bool freeTrial = false, + string status = "active_patron", DateTimeOffset? lastCharge = null, string? chargeStatus = null, + string currency = "USD", string? vanity = null) { + return JsonSerializer.Serialize(new { + data = new { + id = "member-1", type = "member", + attributes = new { + full_name = "Jane Doe", patron_status = status, is_free_trial = freeTrial, + pledge_cadence = cadence, will_pay_amount_cents = willPay, + currently_entitled_amount_cents = entitled, + pledge_relationship_start = "2026-09-01T10:00:00.000+00:00", + last_charge_date = lastCharge?.ToString("O"), last_charge_status = chargeStatus + }, + relationships = new { + currently_entitled_tiers = new { + data = new[] { new { id = "t1", type = "tier" }, new { id = "t2", type = "tier" } } + } + } + }, + included = new object[] { + new { id = "c1", type = "campaign", attributes = new { currency } }, + new { id = "u0", type = "user", attributes = new { full_name = "The Creator", vanity = "creator" } }, + new { id = "u1", type = "user", attributes = new { full_name = "Jane Doe", vanity } }, + new { id = "t1", type = "tier", attributes = new { title = "Bronze", amount_cents = 300 } }, + new { id = "t2", type = "tier", attributes = new { title = "Gold", amount_cents = 500 } } + } + }); + } + + private static async Task SendAsync(PatreonService service, string eventType, string body, + string? signatureSecret = Secret) { + byte[] bytes = Encoding.UTF8.GetBytes(body); + string signature = + Convert.ToHexStringLower(HMACMD5.HashData(Encoding.UTF8.GetBytes(signatureSecret ?? ""), bytes)); + var headers = new Dictionary { + ["Content-Type"] = "application/json", + ["X-Patreon-Event"] = eventType, + ["X-Patreon-Signature"] = signature + }; + return await EventUtil.SubathonEventCapture.CaptureAsync(() => + service.HandleWebhookAsync(bytes, headers, TestContext.Current.CancellationToken)); + } + + private static DateTimeOffset MinutesAgo(int minutes) { + return DateTimeOffset.UtcNow.AddMinutes(-minutes); + } + + [Fact] + public async Task PledgeCreate_CountsTheWillPayAmountAtTheHighestTier() { + SubathonEvent? ev = await SendAsync(MakeService(), PatreonService.PledgeCreated, Payload()); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.Patreon, ev.Source); + Assert.Equal(SubathonEventType.PatreonPledge, ev.EventType); + Assert.Equal("Jane", ev.User); + Assert.Equal("Gold", ev.Value); + Assert.Equal("Gold", ev.EventTypeMeta); + Assert.Equal("member", ev.Currency); + Assert.Equal(1, ev.Amount); + Assert.Equal("5.00|USD", ev.SecondaryValue); + Assert.Equal(new DateTimeOffset(2026, 9, 1, 10, 0, 0, TimeSpan.Zero).LocalDateTime, ev.EventTimestamp); + } + + [Theory] + [InlineData("janedoe", "janedoe")] + [InlineData(null, "Jane")] + [InlineData("", "Jane")] + public async Task User_IsThePatronsVanity_OrTheirName(string? vanity, string expected) { + SubathonEvent? ev = await SendAsync(MakeService(), PatreonService.PledgeCreated, Payload(vanity: vanity)); + + Assert.NotNull(ev); + Assert.Equal(expected, ev.User); + } + + [Fact] + public async Task PayloadTier_OverridesAStaleCachedTier() { + PatreonService service = MakeService(); + service.Tiers["t2"] = ("Old Gold", 400); + + SubathonEvent? ev = await SendAsync(service, PatreonService.PledgeCreated, Payload()); + + Assert.NotNull(ev); + Assert.Equal("Gold", ev.Value); + Assert.Equal(("Gold", 500), service.Tiers["t2"]); + } + + [Fact] + public async Task PledgeCreate_UsesTheCampaignCurrency() { + SubathonEvent? ev = await SendAsync(MakeService(byAmount: true), PatreonService.PledgeCreated, + Payload(currency: "EUR")); + + Assert.NotNull(ev); + Assert.Equal("5.00", ev.Value); + Assert.Equal("EUR", ev.Currency); + Assert.Equal("5.00|EUR", ev.SecondaryValue); + } + + [Theory] + [InlineData(false, "Gold", 12)] + [InlineData(true, "54.00", 1)] + public async Task AnnualPledge_IsTierTimesCadenceOrTheWillPayAmountOnce(bool byAmount, string value, + int amount) { + SubathonEvent? ev = await SendAsync(MakeService(byAmount: byAmount), PatreonService.PledgeCreated, + Payload(5400, 500, 12)); + + Assert.NotNull(ev); + Assert.Equal(value, ev.Value); + Assert.Equal(amount, ev.Amount); + Assert.Equal("54.00|USD", ev.SecondaryValue); + } + + [Fact] + public async Task PledgeUpdate_PaidChargeAtTheSameAmount_CountsInFull() { + SubathonEvent? ev = await SendAsync(MakeService(), PatreonService.PledgeUpdated, + Payload(lastCharge: MinutesAgo(2), chargeStatus: "Paid")); + + Assert.NotNull(ev); + Assert.Equal("Gold", ev.Value); + Assert.Equal("5.00|USD", ev.SecondaryValue); + } + + [Theory] + [InlineData(false, "Gold", "member")] + [InlineData(true, "10.00", "USD")] + public async Task PledgeUpdate_Upgrade_CountsTheTierOrTheWillPayAmount(bool byAmount, string value, + string currency) { + SubathonEvent? ev = await SendAsync(MakeService(byAmount: byAmount), PatreonService.PledgeUpdated, + Payload(1000, 500, lastCharge: MinutesAgo(1), chargeStatus: "Paid")); + + Assert.NotNull(ev); + Assert.Equal(value, ev.Value); + Assert.Equal(currency, ev.Currency); + Assert.Equal(1, ev.Amount); + Assert.Equal("10.00|USD", ev.SecondaryValue); + } + + [Fact] + public async Task PledgeUpdate_AnnualCharge_IsNotMistakenForAnUpgrade() { + SubathonEvent? ev = await SendAsync(MakeService(), PatreonService.PledgeUpdated, + Payload(5400, 500, 12, lastCharge: MinutesAgo(1), chargeStatus: "Paid")); + + Assert.NotNull(ev); + Assert.Equal(12, ev.Amount); + Assert.Equal("54.00|USD", ev.SecondaryValue); + } + + [Theory] + [InlineData(300, 500, 60 * 24 * 10, "Paid")] + [InlineData(500, 500, 2, "Declined")] + [InlineData(500, 500, 120, "Paid")] + public async Task PledgeUpdate_DowngradeOrNoFreshPaidCharge_IsIgnored(int willPay, int entitled, + int chargedMinutesAgo, string? chargeStatus) { + Assert.Null(await SendAsync(MakeService(), PatreonService.PledgeUpdated, + Payload(willPay, entitled, lastCharge: MinutesAgo(chargedMinutesAgo), chargeStatus: chargeStatus))); + } + + [Theory] + [InlineData(PatreonService.PledgeCreated, "declined_patron", false, null)] + [InlineData(PatreonService.PledgeCreated, "former_patron", false, null)] + [InlineData(PatreonService.PledgeCreated, "active_patron", true, null)] + [InlineData(PatreonService.PledgeCreated, "active_patron", false, "Declined")] + [InlineData(PatreonService.PledgeUpdated, "former_patron", false, "Paid")] + public async Task InactiveTrialOrDeclinedPledges_AreIgnored(string eventType, string status, bool freeTrial, + string? chargeStatus) { + Assert.Null(await SendAsync(MakeService(), eventType, + Payload(freeTrial: freeTrial, status: status, lastCharge: MinutesAgo(1), chargeStatus: chargeStatus))); + } + + [Fact] + public async Task MemberUpdate_NewPaidCharge_IsARenewal() { + SubathonEvent? ev = await SendAsync(MakeService(), PatreonService.MemberUpdated, + Payload(lastCharge: MinutesAgo(2), chargeStatus: "Paid")); + + Assert.NotNull(ev); + Assert.Equal("Gold", ev.Value); + Assert.Equal(1, ev.Amount); + Assert.Equal("5.00|USD", ev.SecondaryValue); + } + + [Theory] + [InlineData(null, null)] + [InlineData(2, "Declined")] + [InlineData(120, "Paid")] + public async Task MemberUpdate_WithoutAFreshPaidCharge_IsIgnored(int? chargedMinutesAgo, string? chargeStatus) { + Assert.Null(await SendAsync(MakeService(), PatreonService.MemberUpdated, + Payload(lastCharge: chargedMinutesAgo is { } m ? MinutesAgo(m) : null, chargeStatus: chargeStatus))); + } + + [Fact] + public async Task PledgeUpdate_WithNoChargeDate_IsCounted() { + SubathonEvent? ev = await SendAsync(MakeService(), PatreonService.PledgeUpdated, Payload()); + Assert.NotNull(ev); + Assert.Equal("Gold", ev.Value); + Assert.Equal("5.00|USD", ev.SecondaryValue); + } + + [Theory] + [InlineData(PatreonService.PledgeCreated)] + [InlineData(PatreonService.PledgeUpdated)] + public async Task SameChargeOnMemberUpdate_HasTheSameId(string firstEvent) { + PatreonService service = MakeService(); + DateTimeOffset charged = MinutesAgo(1); + + SubathonEvent? first = await SendAsync(service, firstEvent, Payload(1000, 500, lastCharge: charged, + chargeStatus: "Paid")); + SubathonEvent? same = await SendAsync(service, PatreonService.MemberUpdated, Payload(1000, 1000, + lastCharge: charged, chargeStatus: "Paid")); + SubathonEvent? renewal = await SendAsync(service, PatreonService.MemberUpdated, Payload(1000, 1000, + lastCharge: charged.AddSeconds(30), chargeStatus: "Paid")); + + Assert.NotNull(first); + Assert.NotNull(renewal); + Assert.Equal(first.Id, same?.Id); + Assert.NotEqual(first.Id, renewal.Id); + } + + [Fact] + public async Task RedeliveredWebhook_HasTheSameId() { + PatreonService service = MakeService(); + string body = Payload(); + + SubathonEvent? first = await SendAsync(service, PatreonService.PledgeCreated, body); + SubathonEvent? again = await SendAsync(service, PatreonService.PledgeCreated, body); + + Assert.NotNull(first); + Assert.Equal(first.Id, again?.Id); + } + + [Theory] + [InlineData("members:create", Secret)] + [InlineData("members:pledge:delete", Secret)] + [InlineData(PatreonService.PledgeCreated, "wrong-secret")] + public async Task IgnoredOrUnverifiedWebhooks_RaiseNothing(string eventType, string signedWith) { + Assert.Null(await SendAsync(MakeService(), eventType, Payload(), signedWith)); + } + + [Fact] + public async Task NoStoredSecret_RaisesNothing() { + Assert.Null(await SendAsync(MakeService(false), PatreonService.PledgeCreated, Payload())); + } + + [Theory] + [InlineData("True", true)] + [InlineData("False", false)] + public void PledgeCountsAsDonation_FromMode(string setting, bool expected) { + IConfig config = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { + { ("Patreon", $"{SubathonEventType.PatreonPledge}.CommissionAsDonation"), setting } + }); + var ev = new SubathonEvent { EventType = SubathonEventType.PatreonPledge, SecondaryValue = "5.00|USD" }; + Assert.Equal(expected, Utils.IsCommissionAsDonation(config, ev)); + } + + [Theory] + [InlineData("Silver", true, 12, "96.00|USD")] + [InlineData("Silver", false, 1, "8.00|USD")] + [InlineData("DEFAULT", false, 1, "5.00|USD")] + public void SimulateMembership_ChargesTheTiersPrice(string tier, bool annual, int amount, string charged) { + PatreonService service = MakeService(); + service.Tiers["t9"] = ("Silver", 800); + + SubathonEvent? ev = EventUtil.SubathonEventCapture.Capture(() => service.SimulateMembership(tier, annual)); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.Simulated, ev.Source); + Assert.Equal(tier, ev.Value); + Assert.Equal(amount, ev.Amount); + Assert.Equal(charged, ev.SecondaryValue); + } +} diff --git a/SubathonManager.Tests/IntegrationUnitTests/VTSServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/VTSServiceTests.cs index eb1b0efd..66240f79 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/VTSServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/VTSServiceTests.cs @@ -18,8 +18,7 @@ private static VTSService MakeService( bool enabled = false, string? host = null, string? port = null, - string? token = null, - ITimerService? timerService = null) { + string? token = null) { var logger = new Mock>(); var storage = new InMemorySecureStorage(token != null ? new Dictionary { [StorageKeys.VTubeStudioAuthToken] = token } @@ -30,7 +29,7 @@ private static VTSService MakeService( if (host != null) config.Set(VTSService.ConfigSection, "Host", host); if (port != null) config.Set(VTSService.ConfigSection, "Port", port); - return new VTSService(logger.Object, config, storage, timerService); + return new VTSService(logger.Object, config, storage); } private static async Task> CaptureConnectionsAsync(Func trigger) { @@ -225,41 +224,12 @@ public async Task ApplyExpressionAction_RealActions_FailWhenDisconnected(VtsTogg Assert.False(await MakeService().ApplyExpressionActionAsync("a.exp3.json", action, TestContext.Current.CancellationToken)); } - [Fact] - public async Task ExecuteWheelAction_WhenDisconnected_ReturnsFalse() { - VTSService service = MakeService(); - var action = new VTSWheelAction { - Kind = VtsTargetKind.Expression, - Target = "cat_ears.exp3.json", - ToggleAction = VtsToggleAction.On - }; - - Assert.False(await service.ExecuteWheelActionAsync(action, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task ExecuteWheelAction_WithInvalidAction_ReturnsFalse() { - VTSService service = MakeService(); - var action = new VTSWheelAction { Kind = VtsTargetKind.Expression, Target = "" }; - - Assert.False(await service.ExecuteWheelActionAsync(action, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task ExecuteWheelAction_WhenDisconnected_SchedulesNoRevertTimer() { - var timer = new RecordingTimerService(); - VTSService service = MakeService(timerService: timer); - - var action = new VTSWheelAction { - Kind = VtsTargetKind.Expression, - Target = "cat_ears.exp3.json", - ToggleAction = VtsToggleAction.On, - Duration = TimeSpan.FromSeconds(30), - AfterToggle = VtsToggleAction.Off - }; - - Assert.False(await service.ExecuteWheelActionAsync(action, TestContext.Current.CancellationToken)); - Assert.Empty(timer.Registered); + [Theory] + [InlineData(VtsTargetKind.Expression, "cat_ears.exp3.json")] + [InlineData(VtsTargetKind.Hotkey, "hotkey-id")] + [InlineData(VtsTargetKind.Parameter, "FaceAngleX")] + public async Task HasTarget_WhenDisconnected_ReturnsFalse(VtsTargetKind kind, string target) { + Assert.False(await MakeService().HasTargetAsync(kind, target, TestContext.Current.CancellationToken)); } [Fact] @@ -362,27 +332,4 @@ public string GetInstallId() { return "test-install"; } } - - private sealed class RecordingTimerService : ITimerService { - public List<(string Key, TimeSpan Interval)> Registered { get; } = []; - public List Unregistered { get; } = []; - - public IDisposable Register(string key, TimeSpan interval, Func callback) { - Registered.Add((key, interval)); - return new Noop(); - } - - public IDisposable Register(string key, TimeSpan interval, Action callback) { - Registered.Add((key, interval)); - return new Noop(); - } - - public void Unregister(string key) { - Unregistered.Add(key); - } - - private sealed class Noop : IDisposable { - public void Dispose() { } - } - } } diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs index 15ba8f83..9776505c 100644 --- a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs +++ b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs @@ -10,6 +10,7 @@ using SubathonManager.Data; using SubathonManager.Server; using SubathonManager.Server.Interfaces; +using SubathonManager.Services; // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Tests.ServerUnitTests; @@ -179,6 +180,105 @@ public async Task Commands_Endpoint_Returns_Catalog() { AppServices.Provider = null!; } + [Fact] + public async Task Globals_Endpoint_Returns_Typed_Values_Filtered_By_Widget_Or_Name() { + WebServer server = CreateServer(); + var widgetId = Guid.NewGuid(); + await using (AppDbContext db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) { + db.ActionGlobals.AddRange( + new ActionGlobal { Kind = ActionStoreKind.Global, Name = "deaths", ValueType = ActionValueType.Number, Value = "3.5" }, + new ActionGlobal { Kind = ActionStoreKind.Global, Name = "hat_on", ValueType = ActionValueType.Boolean, Value = "true" }, + new ActionGlobal { Kind = ActionStoreKind.Global, Name = "greeting", ValueType = ActionValueType.Text, Value = "hi" }, + new ActionGlobal { Kind = ActionStoreKind.Secret, Name = "api_key", StorageKey = "x" }); + db.Widgets.Add(new Widget("counter", "counter.html") { Id = widgetId, GlobalVars = "DEATHS,hat_on,gone" }); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + async Task Get(string query) { + var ctx = new MockHttpContext { Method = "GET", Path = "/api/data/globals", QueryString = query }; + await server.InvokeHandleRequest(ctx); + Assert.Equal(200, ctx.StatusCode); + return ctx.ResponseBody; + } + + string all = await Get(""); + Assert.Contains("{\"name\":\"deaths\",\"type\":\"number\",\"value\":3.5}", all); + Assert.Contains("{\"name\":\"hat_on\",\"type\":\"boolean\",\"value\":true}", all); + Assert.Contains("{\"name\":\"greeting\",\"type\":\"text\",\"value\":\"hi\"}", all); + Assert.DoesNotContain("api_key", all); + + string forWidget = await Get($"?widget={widgetId}"); + Assert.Contains("deaths", forWidget); + Assert.Contains("hat_on", forWidget); + Assert.DoesNotContain("greeting", forWidget); + + string byName = await Get("?name=GREETING"); + Assert.Contains("greeting", byName); + Assert.DoesNotContain("deaths", byName); + + var missing = new MockHttpContext { + Method = "GET", Path = "/api/data/globals", QueryString = $"?widget={Guid.NewGuid()}" + }; + await server.InvokeHandleRequest(missing); + Assert.Equal(404, missing.StatusCode); + AppServices.Provider = null!; + } + + [Fact] + public async Task Globals_Post_Creates_Sets_Adds_Toggles_And_Rejects_Bad_Requests() { + WebServer server = CreateServer(); + var services = new ServiceCollection(); + services.AddSingleton(new ActionService(server._factory, [])); + AppServices.Provider = services.BuildServiceProvider(); + + async Task<(int Code, string Body)> Post(string query, string body = "") { + var ctx = new MockHttpContext { + Method = "POST", Path = "/api/data/globals", QueryString = query, + Body = new MemoryStream(Encoding.UTF8.GetBytes(body)) + }; + await server.InvokeHandleRequest(ctx); + return (ctx.StatusCode, ctx.ResponseBody); + } + + Assert.Equal((200, "{\"created\":true,\"global\":{\"name\":\"deaths\",\"type\":\"number\",\"value\":2}}"), + await Post("?name=deaths&op=add&value=2")); + Assert.Contains("\"value\":-1.5", (await Post("?name=debt&op=subtract&value=1.5")).Body); + Assert.Contains("\"value\":true", (await Post("", "{\"name\":\"hat_on\",\"op\":\"toggle\"}")).Body); + Assert.Equal(400, (await Post("?name=greeting&value=hi")).Code); + Assert.Contains("\"value\":\"hi\"", (await Post("?name=greeting&type=text&value=hi")).Body); + Assert.Contains("\"value\":7", (await Post("", "{\"name\":\"lives\",\"value\":7}")).Body); + + Assert.Equal((200, "{\"created\":false,\"global\":{\"name\":\"deaths\",\"type\":\"number\",\"value\":1}}"), + await Post("?name=DEATHS&op=subtract&value=1")); + Assert.Contains("\"value\":false", (await Post("?name=hat_on&op=toggle")).Body); + Assert.Contains("\"value\":true", (await Post("", "{\"name\":\"hat_on\",\"value\":true}")).Body); + Assert.Contains("\"value\":\"bye\"", (await Post("?name=greeting&value=bye")).Body); + + foreach ((string query, string body) in new[] { + ("?name=greeting&op=add&value=1", ""), // text can only be set + ("?name=greeting&op=toggle", ""), + ("", "{\"name\":\"greeting\",\"value\":5}"), // number sent to text + ("?name=deaths&type=boolean&value=true", ""), + ("?name=deaths&op=add&value=lots", ""), + ("?name=deaths&op=add", ""), // value missing + ("?name=deaths", ""), + ("?name=hat_on&value=maybe", ""), + ("?name=deaths&op=multiply&value=2", ""), + ("?name=deaths&type=float&value=2", ""), + ("?value=2", ""), // name missing + ("?name=bad-name&type=text&value=x", ""), + ("", "[1,2]") + }) { + (int code, string error) = await Post(query, body); + Assert.True(code == 400, $"{query} {body} -> {code} {error}"); + Assert.Contains("\"error\"", error); + } + + await using AppDbContext db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken); + Assert.Equal(5, await db.ActionGlobals.CountAsync(cancellationToken: TestContext.Current.CancellationToken)); + AppServices.Provider = null!; + } + [Fact] public async Task DataControl_Invalid_Body_Returns_400() { var ctx = new MockHttpContext { diff --git a/SubathonManager.Tests/ServicesUnitTests/ActionServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/ActionServiceTests.cs new file mode 100644 index 00000000..552236b0 --- /dev/null +++ b/SubathonManager.Tests/ServicesUnitTests/ActionServiceTests.cs @@ -0,0 +1,1089 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Core.Security.Interfaces; +using SubathonManager.Data; +using SubathonManager.Services; +using SubathonManager.Tests.Utility; + +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Tests.ServicesUnitTests; + +[Collection("GlobalState")] +public class ActionServiceTests { + private static async Task<(ActionService service, FakeRunner runner, DbContextOptions options, + SqliteConnection conn)> SetupAsync(ISecureStorage? secureStorage = null) { + var dbName = $"test_{Guid.NewGuid():N}"; + var connectionString = $"DataSource={dbName};Mode=Memory;Cache=Shared;Pooling=False"; + var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(); + + DbContextOptions options = + new DbContextOptionsBuilder().UseSqlite(connectionString).Options; + await using (var db = new AppDbContext(options)) { + await db.Database.EnsureCreatedAsync(); + } + + var factoryMock = new Mock>(); + factoryMock + .Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new AppDbContext(options)); + + foreach (string name in new[] { + "SubathonEventCreated", "SubathonDataUpdate", "SubathonEventProcessed", "PromptRunUpdate", + "PromptRunActionStatusChanged" + }) + typeof(SubathonEvents).GetField(name, BindingFlags.Static | BindingFlags.NonPublic)?.SetValue(null, null); + foreach (string name in new[] { "WheelSpinStatusChanged", "OnSpinsOwedUpdateFromEvent" }) + typeof(WheelEvents).GetField(name, BindingFlags.Static | BindingFlags.NonPublic)?.SetValue(null, null); + + typeof(ActionEvents).GetField("CustomActionRunRequested", BindingFlags.Static | BindingFlags.NonPublic) + ?.SetValue(null, null); + + var runner = new FakeRunner(); + var service = new ActionService(factoryMock.Object, [runner], + actionsFolder: Path.Combine(Path.GetTempPath(), $"sm_actions_{Guid.NewGuid():N}"), + secureStorage: secureStorage); + await service.StartAsync(TestContext.Current.CancellationToken); + return (service, runner, options, connection); + } + + private static async Task AddSpinAsync(DbContextOptions options, + WheelSpinActionType type, string parameter, WheelSpinHistoryStatus status = WheelSpinHistoryStatus.Pending) { + await using var db = new AppDbContext(options); + var wheel = new WheelSet(); + var item = new WheelItem { Text = "Prize", Enabled = true, LinkedWheel = wheel }; + item.Action = new WheelSpinAction { ActionType = type, Parameter = parameter, LinkedItem = item }; + var history = new WheelSpinHistory { LinkedWheel = wheel, LinkedItem = item, Status = status }; + db.WheelSpinHistories.Add(history); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + return history; + } + + private static async Task ReloadAsync(DbContextOptions options, Guid id) { + await using var db = new AppDbContext(options); + return await db.WheelSpinHistories.AsNoTracking() + .FirstAsync(h => h.Id == id, TestContext.Current.CancellationToken); + } + + private static ActionStep Hotkey(string target) { + return new ActionStep { Type = ActionStepType.VtsHotkey, Operation = ActionOperation.Trigger, Target = target }; + } + + private static ActionStep Wait(double seconds) { + return new ActionStep { Type = ActionStepType.Wait, Seconds = seconds }; + } + + [Fact] + public async Task InstantWheelAction_RunsBeforeTheSpinIsAnnounced_AndLandsDone() { + (ActionService service, _, DbContextOptions options, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + WheelSpinHistory history = await AddSpinAsync(options, WheelSpinActionType.AddTime, "5m"); + + var created = new List(); + SubathonEvents.SubathonEventCreated += created.Add; + var statusEvents = new List(); + WheelEvents.WheelSpinStatusChanged += (h, _) => statusEvents.Add(h.Status); + + WheelSpinHistoryStatus? announced = null; + int commandsAtAnnounce = -1; + ActionRunResult result = await service.RunWheelSpinAsync(history.Id, status => { + announced = status; + commandsAtAnnounce = created.Count; + return Task.CompletedTask; + }); + + Assert.Equal(ActionRunResult.Done, result); + Assert.Equal(WheelSpinHistoryStatus.Done, announced); + Assert.Equal(1, commandsAtAnnounce); + Assert.Empty(statusEvents); + + SubathonEvent ev = Assert.Single(created); + Assert.Equal(SubathonCommandType.AddTime, ev.Command); + Assert.Equal(SubathonEventSource.WheelSpin, ev.Source); + Assert.Equal(300, ev.SecondsValue); + Assert.Equal(WheelSpinHistoryStatus.Done, (await ReloadAsync(options, history.Id)).Status); + } + + [Fact] + public async Task VtsChain_IsAnnouncedPending_RunsThroughRunning_AndFinishesDone() { + (ActionService service, FakeRunner runner, DbContextOptions options, SqliteConnection conn) = + await SetupAsync(); + await using SqliteConnection _ = conn; + var vts = new VTSWheelAction { + Kind = VtsTargetKind.Hotkey, Target = "spin", Duration = TimeSpan.FromSeconds(1), + AfterHotkey = VtsHotkeyAfterAction.TriggerAgain + }; + WheelSpinHistory history = await AddSpinAsync(options, WheelSpinActionType.VTubeStudio, + vts.ToParameterString()); + + var statusEvents = new ConcurrentQueue(); + WheelEvents.WheelSpinStatusChanged += (h, _) => statusEvents.Enqueue(h.Status); + + WheelSpinHistoryStatus? announced = null; + ActionRunResult result = await service.RunWheelSpinAsync(history.Id, status => { + announced = status; + return Task.CompletedTask; + }); + + Assert.Equal(ActionRunResult.Done, result); + Assert.Equal(WheelSpinHistoryStatus.Pending, announced); + Assert.Equal([WheelSpinHistoryStatus.Running, WheelSpinHistoryStatus.Done], statusEvents.ToArray()); + Assert.Equal(["spin", "spin"], runner.Ran.ToArray()); + + WheelSpinHistory saved = await ReloadAsync(options, history.Id); + Assert.Equal(WheelSpinHistoryStatus.Done, saved.Status); + Assert.Null(saved.ActionProgress); + } + + [Fact] + public async Task Branches_RunInParallel_AndAJoinWaitsForEveryInput() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + ////////////////////////////////////////// + // +-> wait 0.3s -> slow --+ + // start +-> join + // +-> fast ---------------+ + ////////////////////////////////////////// + var graph = new ActionGraph { + Nodes = [ + new ActionNode { Id = "start", Step = Hotkey("start") }, + new ActionNode { Id = "wait", Step = Wait(0.3) }, + new ActionNode { Id = "slow", Step = Hotkey("slow") }, + new ActionNode { Id = "fast", Step = Hotkey("fast") }, + new ActionNode { Id = "join", Step = Hotkey("join") } + ], + Edges = [ + new ActionEdge { From = "start", To = "wait" }, + new ActionEdge { From = "wait", To = "slow" }, + new ActionEdge { From = "start", To = "fast" }, + new ActionEdge { From = "slow", To = "join" }, + new ActionEdge { From = "fast", To = "join" } + ] + }; + + ActionRunResult result = await service.RunAsync(Guid.NewGuid(), graph, + new ActionContext(SubathonEventSource.WheelSpin, "test", "branches")); + + Assert.Equal(ActionRunResult.Done, result); + Assert.Equal(["start", "fast", "slow", "join"], runner.Ran.ToArray()); + } + + [Fact] + public async Task MultipleInputSteps_RunPerInput_StepsAfterThemRunPerRun_AndResumeRunsWhatsMissing() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + ////////////////////////////////////////////////////////////////// + // +-> wait 0.3s -> slow --+ + // start +-> each (Multiple) -> after (Wait) + // +-> fast ---------------+ + // +-> no (if false) ------+ (skipped) + ////////////////////////////////////////////////////////////////// + ActionGraph Build() { + return new ActionGraph { + Nodes = [ + new ActionNode { Id = "start", Step = Hotkey("start") }, + new ActionNode { Id = "wait", Step = Wait(0.3) }, + new ActionNode { Id = "slow", Step = Hotkey("slow") }, + new ActionNode { Id = "fast", Step = Hotkey("fast") }, + new ActionNode { + Id = "if", Step = new ActionStep { + Type = ActionStepType.Condition, Scope = "1", Operation = ActionOperation.IsEqual, Target = "2" + } + }, + new ActionNode { Id = "no", Step = Hotkey("no") }, + new ActionNode { Id = "each", Step = Hotkey("each"), Inputs = ActionInputMode.Multiple }, + new ActionNode { Id = "after", Step = Hotkey("after") } + ], + Edges = [ + new ActionEdge { From = "start", To = "wait" }, + new ActionEdge { From = "wait", To = "slow" }, + new ActionEdge { From = "start", To = "fast" }, + new ActionEdge { From = "start", To = "if" }, + new ActionEdge { From = "if", To = "no" }, + new ActionEdge { From = "slow", To = "each" }, + new ActionEdge { From = "fast", To = "each" }, + new ActionEdge { From = "no", To = "each" }, + new ActionEdge { From = "each", To = "after" } + ] + }; + } + + var ctx = new ActionContext(SubathonEventSource.WheelSpin, "test", "multiple"); + Assert.Equal(ActionRunResult.Done, await service.RunAsync(Guid.NewGuid(), Build(), ctx)); + Assert.Equal(["start", "fast", "each", "after", "slow", "each", "after"], runner.Ran.ToArray()); + + runner.Ran.Clear(); + runner.FailOnce.Add("each"); + var progress = new ActionRunProgress(); + Assert.Equal(ActionRunResult.Paused, await service.RunAsync(Guid.NewGuid(), Build(), ctx, progress)); + Assert.Equal(["start", "fast"], runner.Ran.ToArray()); + + ActionRunProgress resumed = ActionRunProgress.Parse(progress.ToJson()); + Assert.Equal(ActionRunResult.Done, await service.RunAsync(Guid.NewGuid(), Build(), ctx, resumed)); + + Assert.Equal(["start", "fast", "each", "after", "slow", "each", "after"], runner.Ran.ToArray()); + Assert.Equal(2, resumed.Runs("each")); + Assert.Equal(2, resumed.Runs("after")); + Assert.Contains("no", resumed.Skipped); + } + + [Fact] + public async Task FailedStep_PausesTheRun_AndResumeContinuesFromThatStep() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + runner.FailOnce.Add("flaky"); + + var graph = new ActionGraph { + Nodes = [ + new ActionNode { Id = "1", Step = Hotkey("first") }, + new ActionNode { Id = "2", Step = Hotkey("flaky") }, + new ActionNode { Id = "3", Step = Hotkey("last") }, + new ActionNode { Id = "4", Step = Wait(0.5) }, + new ActionNode { Id = "5", Step = Hotkey("late") } + ], + Edges = [ + new ActionEdge { From = "1", To = "2" }, + new ActionEdge { From = "2", To = "3" }, + new ActionEdge { From = "1", To = "4" }, + new ActionEdge { From = "4", To = "5" } + ] + }; + var ctx = new ActionContext(SubathonEventSource.WheelSpin, "test", "resume"); + var progress = new ActionRunProgress(); + + Assert.Equal(ActionRunResult.Paused, await service.RunAsync(Guid.NewGuid(), graph, ctx, progress)); + Assert.Equal(["1"], progress.Done.Order()); + Assert.Equal(["first"], runner.Ran.ToArray()); + + progress = ActionRunProgress.Parse(progress.ToJson()); + Assert.Equal(ActionRunResult.Done, await service.RunAsync(Guid.NewGuid(), graph, ctx, progress)); + Assert.Equal(["first", "flaky", "last", "late"], runner.Ran.ToArray()); + } + + [Fact] + public async Task MarkingARunningSpinDone_StopsTheChain_AndKeepsTheNewStatus() { + (ActionService service, FakeRunner runner, DbContextOptions options, SqliteConnection conn) = + await SetupAsync(); + await using SqliteConnection _ = conn; + var vts = new VTSWheelAction { + Kind = VtsTargetKind.Hotkey, Target = "spin", Duration = TimeSpan.FromSeconds(30), + AfterHotkey = VtsHotkeyAfterAction.TriggerAgain + }; + WheelSpinHistory history = await AddSpinAsync(options, WheelSpinActionType.VTubeStudio, + vts.ToParameterString()); + + Task run = service.RunWheelSpinAsync(history.Id); + for (var i = 0; i < 100 && !service.IsRunning(history.Id); i++) + await Task.Delay(10, TestContext.Current.CancellationToken); + Assert.True(service.IsRunning(history.Id)); + + await using (var db = new AppDbContext(options)) { + WheelSpinHistory tracked = await db.WheelSpinHistories.FirstAsync(h => h.Id == history.Id, + TestContext.Current.CancellationToken); + + tracked.Status = WheelSpinHistoryStatus.Done; + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + WheelEvents.RaiseWheelSpinStatusChanged(tracked, 0); + } + + Assert.Equal(ActionRunResult.Cancelled, await run); + Assert.Equal(["spin"], runner.Ran.ToArray()); + Assert.Equal(WheelSpinHistoryStatus.Done, (await ReloadAsync(options, history.Id)).Status); + } + + [Fact] + public async Task RepeatOnTheSameKey_RestartsTheChain_SoOnlyTheNewestRevertFires() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + ActionGraph graph = ActionGraph.Sequence(Hotkey("on"), Wait(0.4), Hotkey("off")); + var ctx = new ActionContext(SubathonEventSource.WheelSpin, "test", "same-target"); + + Task first = service.RunAsync(Guid.NewGuid(), graph, ctx); + await Task.Delay(100, TestContext.Current.CancellationToken); + Task second = service.RunAsync(Guid.NewGuid(), graph, ctx); + + Assert.Equal(ActionRunResult.Done, await first); + Assert.Equal(ActionRunResult.Done, await second); + Assert.Equal(["on", "on", "off"], runner.Ran.ToArray()); + } + + [Fact] + public async Task Startup_ReturnsRunsInterruptedByARestartToPending() { + (ActionService service, _, DbContextOptions options, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + WheelSpinHistory history = await AddSpinAsync(options, WheelSpinActionType.VTubeStudio, "", + WheelSpinHistoryStatus.Running); + + await service.StartAsync(TestContext.Current.CancellationToken); + + Assert.Equal(WheelSpinHistoryStatus.Pending, (await ReloadAsync(options, history.Id)).Status); + } + + [Fact] + public void VtsParameterAction_ConvertsToAChain_ThatSurvivesJson() { + var vts = new VTSWheelAction { + Kind = VtsTargetKind.Parameter, Target = "FaceAngleX", Value = 30, + Duration = TimeSpan.FromSeconds(10), AfterParameter = VtsParameterAfterAction.ResetToOriginal + }; + + Assert.True(ActionGraph.TryParse(vts.ToActionGraph().ToJson(), out ActionGraph? graph)); + Assert.True(graph.IsValid(out _)); + Assert.Equal([ActionOperation.Hold, ActionOperation.None, ActionOperation.Restore], + graph.Nodes.Select(n => n.Step.Operation)); + Assert.Equal("FaceAngleX = 30 (held) -> Wait 10s -> Restore FaceAngleX", graph.Describe()); + + graph.Edges.Add(new ActionEdge { From = "3", To = "1" }); + Assert.False(graph.IsValid(out string error)); + Assert.Contains("loop", error); + } + + [Fact] + public void ObsWheelParameter_IsTheGraph_AndRepeatsShareAKeyPerTarget() { + var show = new ActionStep { + Type = ActionStepType.ObsSourceVisibility, Operation = ActionOperation.Show, Target = "Hat", Scope = "Main" + }; + var hide = new ActionStep { + Type = ActionStepType.ObsSourceVisibility, Operation = ActionOperation.Hide, Target = "Hat", Scope = "Main" + }; + string longer = ActionGraph.Sequence(show, Wait(30), hide).ToJson(); + string shorter = ActionGraph.Sequence(show, Wait(5), hide).ToJson(); + + ActionGraph? graph = WheelSpinActionType.OBS.BuildActionGraph(longer); + Assert.NotNull(graph); + Assert.True(graph.IsValid(out _)); + Assert.Equal("Show \"Hat\" in \"Main\" -> Wait 30s -> Hide \"Hat\" in \"Main\"", graph.Describe()); + Assert.Equal(WheelSpinActionType.OBS.RepeatKey(longer, Guid.NewGuid()), + WheelSpinActionType.OBS.RepeatKey(shorter, Guid.NewGuid())); + + show.Scope = null; + Assert.False(ActionGraph.Sequence(show).IsValid(out string error)); + Assert.Contains("scene", error); + Assert.Null(WheelSpinActionType.OBS.BuildActionGraph("not json")); + } + + [Fact] + public async Task CustomActions_LiveAsFiles_KeepTheirIdentity_AndRunFromAWheelItem() { + (ActionService service, FakeRunner runner, DbContextOptions options, SqliteConnection conn) = + await SetupAsync(); + await using SqliteConnection _ = conn; + string folder = Path.Combine(Path.GetTempPath(), $"sm_actions_{Guid.NewGuid():N}"); + try { + var editor = new ActionService(null!, [runner], actionsFolder: folder); + await editor.LoadLibraryAsync(); + var action = new CustomAction { + Name = "Hat Bit", Graph = ActionGraph.Sequence(Hotkey("on"), Hotkey("off")) + }; + await editor.SaveCustomActionAsync(action); + + action.Name = "Hat Bit (long)"; + await editor.SaveCustomActionAsync(action); + string file = Assert.Single(Directory.GetFiles(folder, $"*{CustomAction.FileExtension}")); + Assert.Equal("hat-bit.sma", Path.GetFileName(file)); + var reopened = new ActionService(null!, [runner], actionsFolder: folder); + await reopened.LoadLibraryAsync(); + Assert.Equal("Hat Bit (long)", reopened.GetCustomAction(action.Id)?.Name); + + (CustomAction Action, bool Replaced)? imported = await service.ImportCustomActionAsync(file); + Assert.False(imported?.Replaced); + WheelSpinHistory history = await AddSpinAsync(options, WheelSpinActionType.CustomAction, + action.Id.ToString()); + var logged = new List(); + SubathonEvents.SubathonEventCreated += logged.Add; + Assert.Equal(ActionRunResult.Done, await service.RunWheelSpinAsync(history.Id)); + Assert.Equal(["on", "off"], runner.Ran.ToArray()); + + SubathonEvent record = Assert.Single(logged); + Assert.Equal(SubathonCommandType.RunAction, record.Command); + Assert.Equal("WheelSpin", record.User); + Assert.Equal("RunAction Hat Bit (long)", record.Value); + ActionEvents.RaiseCustomActionRunRequested(record); + await Task.Delay(100, TestContext.Current.CancellationToken); + Assert.Equal(2, runner.Ran.Count); + + imported = await service.ImportCustomActionAsync(file); + Assert.True(imported?.Replaced); + Assert.Single(service.CustomActions); + } + finally { + if (Directory.Exists(folder)) Directory.Delete(folder, true); + } + } + + [Fact] + public async Task OpeningASmaFromOutside_AlwaysAddsACopy_RenamingClashes() { + string folder = Path.Combine(Path.GetTempPath(), $"sm_actions_{Guid.NewGuid():N}"); + string outside = Path.Combine(Path.GetTempPath(), $"sm_shared_{Guid.NewGuid():N}.sma"); + try { + var original = new CustomAction { Name = "Hat Bit", Graph = ActionGraph.Sequence(Hotkey("on")) }; + await File.WriteAllTextAsync(outside, original.ToJson(), TestContext.Current.CancellationToken); + + var service = new ActionService(null!, [new FakeRunner()], actionsFolder: folder); + CustomAction? first = await service.AddCustomActionCopyAsync(outside); + CustomAction? second = await service.AddCustomActionCopyAsync(outside); + CustomAction? third = await service.AddCustomActionCopyAsync(outside); + + Assert.Equal("Hat Bit", first?.Name); + Assert.Equal(original.Id, first?.Id); + Assert.Equal("Hat Bit (Copy 1)", second?.Name); + Assert.Equal("Hat Bit (Copy 2)", third?.Name); + Assert.Equal(3, new[] { first!.Id, second!.Id, third!.Id }.Distinct().Count()); + + CustomAction duplicate = await service.DuplicateCustomActionAsync(third); + Assert.Equal("Hat Bit (Copy 3)", duplicate.Name); + Assert.NotEqual(third.Id, duplicate.Id); + Assert.Equal(4, service.CustomActions.Count); + + var reopened = new ActionService(null!, [new FakeRunner()], actionsFolder: folder); + await reopened.LoadLibraryAsync(); + Assert.Equal(4, reopened.CustomActions.Count); + Assert.Null(await service.AddCustomActionCopyAsync(Path.Combine(folder, "missing.sma"))); + } + finally { + if (Directory.Exists(folder)) Directory.Delete(folder, true); + if (File.Exists(outside)) File.Delete(outside); + } + } + + [Fact] + public async Task DisabledSteps_AreSkipped_AndWhatFollowsStillRuns() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + + var graph = new ActionGraph { + Nodes = [ + new ActionNode { Id = "1", Step = Hotkey("first") }, + new ActionNode { Id = "2", Step = Hotkey("skipped"), Disabled = true }, + new ActionNode { Id = "3", Step = Hotkey(""), Disabled = true }, + new ActionNode { Id = "4", Step = Hotkey("last") } + ], + Edges = [ + new ActionEdge { From = "1", To = "2" }, + new ActionEdge { From = "1", To = "3" }, + new ActionEdge { From = "2", To = "4" }, + new ActionEdge { From = "3", To = "4" } + ] + }; + + Assert.True(graph.IsValid(out _)); + Assert.True(ActionGraph.TryParse(graph.ToJson(), out ActionGraph? reloaded)); + Assert.Equal([false, true, true, false], reloaded.Nodes.Select(n => n.Disabled)); + + ActionRunResult result = await service.RunAsync(Guid.NewGuid(), reloaded, + new ActionContext(SubathonEventSource.WheelSpin, "test", "disabled")); + Assert.Equal(ActionRunResult.Done, result); + Assert.Equal(["first", "last"], runner.Ran.ToArray()); + } + + [Fact] + public async Task RunActionCommand_RunsTheNamedAction_AsWhoeverAskedOrSystem() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + await service.SaveCustomActionAsync(new CustomAction { + Name = "Hat Bit", Graph = ActionGraph.Sequence(Hotkey("hat")) + }); + + ActionEvents.RaiseCustomActionRunRequested(new SubathonEvent { + Source = SubathonEventSource.Twitch, User = "SomeMod", Value = " hat bit " + }); + for (var i = 0; i < 100 && runner.Ran.IsEmpty; i++) await Task.Delay(10, TestContext.Current.CancellationToken); + Assert.Equal(["hat"], runner.Ran.ToArray()); + Assert.Equal("SomeMod", runner.LastUser); + + ActionEvents.RaiseCustomActionRunRequested(new SubathonEvent { Value = "RunAction Hat Bit" }); + for (var i = 0; i < 100 && runner.Ran.Count < 2; i++) + await Task.Delay(10, TestContext.Current.CancellationToken); + Assert.Equal("SYSTEM", runner.LastUser); + + ActionEvents.RaiseCustomActionRunRequested(new SubathonEvent { Value = "Hat" }); + await Task.Delay(100, TestContext.Current.CancellationToken); + Assert.Equal(2, runner.Ran.Count); + Assert.Null(service.FindCustomAction("Hat")); + } + + [Fact] + public async Task Variables_ResolveFromTheActiveSubathon_AndUnknownTokensAreLeftAlone() { + (_, _, DbContextOptions options, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + await using (var db = new AppDbContext(options)) { + foreach (SubathonData existing in db.SubathonDatas) existing.IsActive = false; + foreach (SubathonGoalSet existing in db.SubathonGoalSets) existing.IsActive = false; + db.SubathonDatas.Add(new SubathonData { + IsActive = true, IsLocked = true, IsPaused = false, Points = 150, MoneySum = 12.5, Currency = "", + MillisecondsCumulative = 3_725_000, MillisecondsElapsed = 0, + Multiplier = new MultiplierData { + Multiplier = 2, ApplyToPoints = true, ApplyToSeconds = false, + Duration = TimeSpan.FromMinutes(10), Started = DateTime.Now + } + }); + db.SubathonGoalSets.Add(new SubathonGoalSet { + IsActive = true, Name = "Main", Type = GoalsType.Points, + Goals = [ + new SubathonGoal { Text = "C", Points = 300 }, + new SubathonGoal { Text = "A", Points = 100 }, + new SubathonGoal { Text = "B", Points = 200 } + ] + }); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var factory = new Mock>(); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new AppDbContext(options)); + IConfig config = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { + { ("Currency", "Primary"), "CAD" } + }); + + string filled = await ActionVariableResolver.FillAsync(factory.Object, + "%points% %money% %currency% %timer_locked% %timer_paused% %time_remaining% %seconds_remaining% | " + + "%goal_text% %goal_index%/%goal_count% last=%last_goal_text% | x%multiplier_amount% " + + "pts=%multiplier_points% time=%multiplier_time% | %user% %not_a_variable%", + new ActionContext(SubathonEventSource.WheelSpin, "tester", "key"), config); + + Assert.Equal("150 12.50 CAD true false 01:02:05 3725 | B 2/3 last=A | x2 pts=true time=false | " + + "tester %not_a_variable%", filled); + } + + [Fact] + public void StepArguments_AreNameValueLines() { + List<(string Name, string Value)> args = ActionStep + .ParseArguments("points=%points%\r\n bad line \nmessage = hi = there\n=nameless\n").ToList(); + Assert.Equal(new[] { ("points", "%points%"), ("message", "hi = there") }, args); + } + + [Fact] + public async Task IfElse_RunsOnlyTakenSide_JoinOfBothSidesRunsOnce() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + + ActionGraph Build() { + return new ActionGraph { + Nodes = [ + new ActionNode { Id = "1", Step = Hotkey("fetch") }, + new ActionNode { + Id = "2", Step = new ActionStep { + Type = ActionStepType.Condition, Operation = ActionOperation.MoreThan, + Scope = "%resp.data.items[1].count%", Target = "3" + } + }, + new ActionNode { Id = "3", Step = Hotkey("then") }, + new ActionNode { Id = "4", Step = Hotkey("then-more") }, + new ActionNode { Id = "5", Step = Hotkey("else") }, + new ActionNode { Id = "6", Step = Hotkey("join") } + ], + Edges = [ + new ActionEdge { From = "1", To = "2" }, + new ActionEdge { From = "2", To = "3" }, + new ActionEdge { From = "3", To = "4" }, + new ActionEdge { From = "2", To = "5", Port = ActionEdge.ElsePort }, + new ActionEdge { From = "4", To = "6" }, + new ActionEdge { From = "5", To = "6" } + ] + }; + } + + Assert.True(ActionGraph.TryParse(Build().ToJson(), out ActionGraph? reloaded)); + Assert.Equal(ActionEdge.ElsePort, reloaded.Edges.Single(e => e.To == "5").Port); + Assert.DoesNotContain("\"port\"", ActionGraph.Sequence(Hotkey("a"), Hotkey("b")).ToJson()); + + runner.SetVariables["fetch"] = ("resp", """{"data":{"items":[{"count":1},{"count":5}]}}"""); + var progress = new ActionRunProgress(); + Assert.Equal(ActionRunResult.Done, await service.RunAsync(Guid.NewGuid(), reloaded, + new ActionContext(SubathonEventSource.WheelSpin, "test", "if-true"), progress)); + Assert.Equal(["fetch", "then", "then-more", "join"], runner.Ran.ToArray()); + Assert.Equal(["5"], progress.Skipped.ToArray()); + + runner.Ran.Clear(); + runner.SetVariables["fetch"] = ("resp", """{"data":{"items":[{"count":1},{"count":2}]}}"""); + progress = new ActionRunProgress(); + Assert.Equal(ActionRunResult.Done, await service.RunAsync(Guid.NewGuid(), Build(), + new ActionContext(SubathonEventSource.WheelSpin, "test", "if-false"), progress)); + Assert.Equal(["fetch", "else", "join"], runner.Ran.ToArray()); + Assert.Equal(["3", "4"], progress.Skipped.Order().ToArray()); + + ActionRunProgress resumed = ActionRunProgress.Parse(progress.ToJson()); + Assert.Equal(ActionEdge.ElsePort, resumed.Ports["2"]); + Assert.True(resumed.TryReadVariable("RESP.data.items[0].count", out string count)); + Assert.Equal("1", count); + } + + [Fact] + public async Task IgnoreErrors_LetsTheRunCarryOn_WhileOtherFailuresStillPause() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + + ActionGraph graph = ActionGraph.Sequence(Hotkey("flaky"), Hotkey("after")); + graph.Nodes[0].IgnoreErrors = true; + Assert.True(ActionGraph.TryParse(graph.ToJson(), out ActionGraph? reloaded)); + Assert.True(reloaded.Nodes[0].IgnoreErrors); + Assert.DoesNotContain("ignore_errors", ActionGraph.Sequence(Hotkey("a")).ToJson()); + + runner.FailOnce.Add("flaky"); + Assert.Equal(ActionRunResult.Done, await service.RunAsync(Guid.NewGuid(), reloaded, + new ActionContext(SubathonEventSource.WheelSpin, "test", "ignore"))); + Assert.Equal(["after"], runner.Ran.ToArray()); + + runner.FailOnce.Add("flaky"); + reloaded.Nodes[0].IgnoreErrors = false; + Assert.Equal(ActionRunResult.Paused, await service.RunAsync(Guid.NewGuid(), reloaded, + new ActionContext(SubathonEventSource.WheelSpin, "test", "no-ignore"))); + Assert.Equal(["after"], runner.Ran.ToArray()); + } + + [Fact] + public async Task Secrets_FillIntoSteps_AreAddedEmptyForSavedActions_AndCanBeDeleted() { + var store = new Dictionary(); + var storage = new Mock(); + storage.Setup(s => s.Set(It.IsAny(), It.IsAny())) + .Returns((string key, string value) => { + store[key] = value; + return true; + }); + storage.Setup(s => s.Get(It.IsAny())).Returns((string key) => store.GetValueOrDefault(key)); + storage.Setup(s => s.Delete(It.IsAny())).Returns((string key) => store.Remove(key)); + + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(storage.Object); + await using SqliteConnection __ = conn; + + Assert.False(await service.SetSecretAsync("bad name", "x")); + Assert.True(await service.SetSecretAsync("Api_Key", "abc123")); + Assert.Equal("abc123", service.GetSecretValue("api_key")); + + var step = new ActionStep { + Type = ActionStepType.MixItUpCommand, Operation = ActionOperation.Run, Target = "cmd", + Body = "key=%secret.API_KEY%\nother=%secret.missing_one%\nleft=%resp.x%" + }; + await service.SaveCustomActionAsync(new CustomAction + { Name = "Uses secrets", Graph = ActionGraph.Sequence(step) }); + + Assert.Equal(["Api_Key", "missing_one"], + (await service.GetGlobalsAsync(ActionStoreKind.Secret)).Select(s => s.Name) + .Order(StringComparer.OrdinalIgnoreCase).ToArray()); + Assert.Null(service.GetSecretValue("missing_one")); + Assert.Single(service.ActionsUsing(ActionStoreKind.Secret, "api_key")); + + Assert.Equal(ActionRunResult.Done, await service.RunAsync(Guid.NewGuid(), ActionGraph.Sequence(step), + new ActionContext(SubathonEventSource.WheelSpin, "test", "secrets"))); + Assert.Equal("key=abc123\nother=\nleft=%resp.x%", runner.LastBody); + + await service.DeleteGlobalAsync(ActionStoreKind.Secret, "API_KEY"); + Assert.Null(service.GetSecretValue("api_key")); + Assert.Equal(["missing_one"], + (await service.GetGlobalsAsync(ActionStoreKind.Secret)).Select(s => s.Name).ToArray()); + } + + [Fact] + public async Task Globals_AreTyped_SetGlobalStepsCheckTheType_AndTypeChangesConvertOrClear() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + + Assert.NotNull(await service.SetGlobalAsync("deaths", ActionValueType.Number, "nope")); + Assert.Null(await service.SetGlobalAsync("Deaths", ActionValueType.Number, " 2.50 ")); + Assert.Null(await service.SetGlobalAsync("hat_on", ActionValueType.Boolean, "yes")); + Assert.Null(await service.SetGlobalAsync("greeting", ActionValueType.Text, "hi there ")); + + ActionStep Set(string name, ActionOperation op, string? body = null) { + return new ActionStep { Type = ActionStepType.SetGlobal, Operation = op, Target = name, Body = body }; + } + + var report = new ActionStep { + Type = ActionStepType.MixItUpCommand, Operation = ActionOperation.Run, Target = "report", + Body = "d=%global.deaths%\nh=%global.HAT_ON%\ng=%global.greeting%\nm=%global.missing%" + }; + ActionGraph graph = ActionGraph.Sequence(Set("deaths", ActionOperation.Adjust, "1"), + Set("hat_on", ActionOperation.Toggle), Set("greeting", ActionOperation.Set, "%user% says hi"), report); + Assert.Equal(ActionRunResult.Done, await service.RunAsync(Guid.NewGuid(), graph, + new ActionContext(SubathonEventSource.WheelSpin, "Bob", "globals"))); + Assert.Equal("d=3.5\nh=false\ng=Bob says hi\nm=", runner.LastBody); + + Assert.Equal(ActionRunResult.Paused, await service.RunAsync(Guid.NewGuid(), + ActionGraph.Sequence(Set("hat_on", ActionOperation.Set, "maybe")), + new ActionContext(SubathonEventSource.WheelSpin, "Bob", "bad-type"))); + Assert.Equal(ActionRunResult.Paused, await service.RunAsync(Guid.NewGuid(), + ActionGraph.Sequence(Set("greeting", ActionOperation.Adjust, "1")), + new ActionContext(SubathonEventSource.WheelSpin, "Bob", "bad-op"))); + Assert.Equal(ActionRunResult.Paused, await service.RunAsync(Guid.NewGuid(), + ActionGraph.Sequence(Set("nobody", ActionOperation.Set, "x")), + new ActionContext(SubathonEventSource.WheelSpin, "Bob", "missing"))); + + Assert.True(await service.SetGlobalTypeAsync("hat_on", ActionValueType.Number)); + Assert.False(await service.SetGlobalTypeAsync("greeting", ActionValueType.Number)); + Assert.True(await service.SetGlobalTypeAsync("deaths", ActionValueType.Text)); + Dictionary globals = (await service.GetGlobalsAsync(ActionStoreKind.Global)) + .ToDictionary(g => g.Name); + Assert.Equal(("0", ActionValueType.Number), (globals["hat_on"].Value, globals["hat_on"].ValueType)); + Assert.Equal("0", globals["greeting"].Value); + Assert.Equal("3.5", globals["Deaths"].Value); + + await service.SaveCustomActionAsync(new CustomAction { + Name = "Counter", Graph = ActionGraph.Sequence(Set("wins", ActionOperation.Adjust, "1"), report) + }); + ActionGlobal wins = (await service.GetGlobalsAsync(ActionStoreKind.Global)).Single(g => g.Name == "wins"); + Assert.Equal(ActionValueType.Number, wins.ValueType); + Assert.Contains(await service.GetGlobalsAsync(ActionStoreKind.Global), g => g.Name == "missing"); + Assert.Single(service.ActionsUsing(ActionStoreKind.Global, "WINS")); + } + + [Fact] + public async Task WidgetGlobals_ImportCreatesMissing_WarnsOnTypeMismatch_AndChangesAreAnnounced() { + (ActionService service, _, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + + var announced = new List(); + void OnUpdated(IReadOnlyCollection names) { + lock (announced) announced.AddRange(names); + } + + var warnings = new List<(string Source, IReadOnlyList Conflicts)>(); + service.GlobalTypeConflictsFound += (source, conflicts) => warnings.Add((source, conflicts)); + ActionEvents.GlobalsUpdated += OnUpdated; + try { + Assert.Null(await service.SetGlobalAsync("deaths", ActionValueType.Text, "lots")); + Assert.Contains("deaths", announced); + + var widget = new Widget("counter", "counter.html") { GlobalVars = "Deaths, hat_on,deaths" }; + Assert.Equal(["Deaths", "hat_on"], widget.GlobalVarNames); + Assert.True(widget.ListensToGlobal("HAT_ON")); + Assert.False(widget.ListensToGlobal("other")); + Assert.True(new Widget("all", "all.html") { GlobalVars = " * " }.ListensToGlobal("anything")); + + WidgetMetaGlobals meta = WidgetMetaGlobals.From(widget, await service.GetGlobalTypesAsync())!; + Assert.Equal(ActionValueType.Text, meta.Vars["Deaths"]); + meta.Vars["Deaths"] = ActionValueType.Number; + meta.Vars["hat_on"] = ActionValueType.Boolean; + Assert.Equal("Deaths,hat_on", meta.ToWidgetValue()); + + IReadOnlyList conflicts = + await service.EnsureTypedGlobalsAsync(meta.Vars, "Widget \"counter\""); + Assert.Equal(new GlobalTypeConflict("deaths", ActionValueType.Text, ActionValueType.Number), + Assert.Single(conflicts)); + Assert.Equal("Widget \"counter\"", Assert.Single(warnings).Source); + + ActionGlobal hat = (await service.GetGlobalsAsync(ActionStoreKind.Global)).Single(g => g.Name == "hat_on"); + Assert.Equal(("false", ActionValueType.Boolean), (hat.Value, hat.ValueType)); + Assert.Contains("hat_on", announced); + Assert.Equal(false, hat.ValueType.ToTypedValue(hat.Value)); + Assert.Equal(2.5, ActionValueType.Number.ToTypedValue("2.5")); + Assert.Equal(0d, ActionValueType.Number.ToTypedValue("junk")); + + await service.SaveCustomActionAsync(new CustomAction { + Name = "Death counter", Graph = ActionGraph.Sequence(new ActionStep { + Type = ActionStepType.SetGlobal, Operation = ActionOperation.Adjust, Target = "deaths", Body = "1" + }) + }); + Assert.Single(warnings); + + await service.DeleteGlobalAsync(ActionStoreKind.Global, "hat_on"); + Assert.Equal(2, announced.Count(n => n == "hat_on")); + } + finally { + ActionEvents.GlobalsUpdated -= OnUpdated; + } + } + + [Fact] + public async Task TriggerSteps_StartOnlyForTheirTrigger_PassItsValues_AndManualRunsSkipThem() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + + var graph = new ActionGraph { + Nodes = [ + new ActionNode { + Id = "1", Step = new ActionStep { + Type = ActionStepType.Trigger, Trigger = SubathonTrigger.SubathonEvent, + EventTypes = [$"{SubathonEventType.TwitchSub}"], IgnoreSimulated = true + } + }, + new ActionNode { + Id = "2", Step = new ActionStep { + Type = ActionStepType.MixItUpCommand, Operation = ActionOperation.Run, Target = "thanks", + Body = "who=%trigger.user%\ntype=%trigger.eventtype%" + } + }, + new ActionNode { Id = "3", Step = Hotkey("manual") } + ], + Edges = [new ActionEdge { From = "1", To = "2" }] + }; + + Assert.True(ActionGraph.TryParse(graph.ToJson(), out ActionGraph? reloaded)); + Assert.Equal(SubathonTrigger.SubathonEvent, reloaded.Nodes[0].Step.Trigger); + Assert.False(ActionStep.IsValidOutputName("trigger")); + await service.SaveCustomActionAsync(new CustomAction { Name = "Sub thanks", Graph = reloaded }); + + async Task FireAsync(SubathonEvent ev, int expectRuns) { + SubathonEvents.RaiseSubathonEventProcessed(ev, true); + for (var i = 0; i < 100 && runner.Ran.Count < expectRuns; i++) + await Task.Delay(10, TestContext.Current.CancellationToken); + await Task.Delay(100, TestContext.Current.CancellationToken); + } + + await FireAsync(new SubathonEvent { + EventType = SubathonEventType.TwitchSub, Source = SubathonEventSource.Twitch, User = "Bob", + ProcessedToSubathon = true + }, 1); + Assert.Equal(["thanks"], runner.Ran.ToArray()); + Assert.Equal("who=Bob\ntype=TwitchSub", runner.LastBody); + + await FireAsync(new SubathonEvent { + EventType = SubathonEventType.TwitchFollow, Source = SubathonEventSource.Twitch, ProcessedToSubathon = true + }, 0); + await FireAsync(new SubathonEvent { + EventType = SubathonEventType.TwitchSub, Source = SubathonEventSource.Simulated, ProcessedToSubathon = true + }, 0); + await FireAsync(new SubathonEvent { + EventType = SubathonEventType.TwitchSub, Source = SubathonEventSource.Twitch, ProcessedToSubathon = false + }, 0); + await FireAsync(new SubathonEvent { + EventType = SubathonEventType.TwitchSub, Source = SubathonEventSource.Twitch, ProcessedToSubathon = true, + EventTypeMeta = ActionService.FromActionMeta + }, 0); + + Assert.Single(runner.Ran); + + CustomAction saved = service.FindCustomAction("Sub thanks")!; + Assert.Equal(ActionRunResult.Done, await service.RunManuallyAsync(saved)); + Assert.Equal(["thanks", "manual"], runner.Ran.ToArray()); + + Assert.Equal(ActionRunResult.Done, await service.TestTriggerAsync(saved, "1")); + Assert.Equal(["thanks", "manual", "thanks"], runner.Ran.ToArray()); + Assert.Equal("who=TestUser\ntype=TwitchSub", runner.LastBody); + + reloaded.Edges.Add(new ActionEdge { From = "3", To = "1" }); + Assert.False(reloaded.IsValid(out _)); + } + + [Fact] + public async Task QueuedRepeats_WaitTheirTurn_AndTriggerActionsNeverRestartOrIgnore() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + + async Task RunTwiceAsync(ActionRepeatMode mode) { + runner.Ran.Clear(); + ActionGraph graph = ActionGraph.Sequence(Hotkey("start"), Wait(0.3), Hotkey("end")); + graph.OnRepeat = mode; + var ctx = new ActionContext(SubathonEventSource.WheelSpin, "test", $"queue-{mode}"); + Task first = service.RunAsync(Guid.NewGuid(), graph, ctx); + await Task.Delay(50, TestContext.Current.CancellationToken); + Task second = service.RunAsync(Guid.NewGuid(), graph, ctx); + Assert.Equal([ActionRunResult.Done, ActionRunResult.Done], await Task.WhenAll(first, second)); + return runner.Ran.ToArray(); + } + + Assert.Equal(["start", "end", "start", "end"], await RunTwiceAsync(ActionRepeatMode.Queue)); + Assert.Equal(["start", "start", "end", "end"], await RunTwiceAsync(ActionRepeatMode.Parallel)); + + var withTrigger = new ActionGraph { + OnRepeat = ActionRepeatMode.Restart, + Nodes = [ + new ActionNode { + Id = "1", Step = new ActionStep { Type = ActionStepType.Trigger, Trigger = SubathonTrigger.TimerPaused } + } + ] + }; + + Assert.Equal(ActionRepeatMode.Parallel, withTrigger.EffectiveRepeat); + withTrigger.OnRepeat = ActionRepeatMode.Queue; + Assert.Equal(ActionRepeatMode.Queue, withTrigger.EffectiveRepeat); + } + + [Fact] + public void TimerEnded_FiresOnceAtZero_AndAgainOnlyAfterTimeWasAddedBack() { + typeof(SubathonEvents).GetField("SubathonDataUpdate", BindingFlags.Static | BindingFlags.NonPublic) + ?.SetValue(null, null); + var fired = new List(); + var watcher = new SubathonTriggerWatcher((t, _, _) => fired.Add(t)); + watcher.Start(); + try { + var id = Guid.NewGuid(); + void Update(long cumulative, long elapsed) { + SubathonEvents.RaiseSubathonDataUpdate(new SubathonData { + Id = id, MillisecondsCumulative = cumulative, MillisecondsElapsed = elapsed + }, DateTime.Now); + } + + Update(10_000, 0); + Update(10_000, 10_000); + Update(10_000, 12_000); + Assert.Equal([SubathonTrigger.TimerEnded], fired.Where(t => t == SubathonTrigger.TimerEnded).ToArray()); + + Update(70_000, 12_000); + Update(70_000, 70_000); + Assert.Equal(2, fired.Count(t => t == SubathonTrigger.TimerEnded)); + } + finally { + watcher.Stop(); + } + } + + [Fact] + public async Task PromptActions_RunOnceWhenCompleted_AndFailedOnesCanBeRetried() { + (ActionService service, FakeRunner runner, DbContextOptions options, SqliteConnection conn) = + await SetupAsync(); + await using SqliteConnection __ = conn; + CancellationToken ct = TestContext.Current.CancellationToken; + + var action = new CustomAction { Name = "Prize", Graph = ActionGraph.Sequence(Hotkey("confetti"), Hotkey("bell")) }; + await service.SaveCustomActionAsync(action); + + async Task AddRun(SubathonPromptRunStatus status, Guid? actionId) { + await using var db = new AppDbContext(options); + var set = new SubathonPromptSet(); + var prompt = new SubathonPrompt { Text = "Get 5 subs", LinkedSet = set, CustomActionId = actionId }; + var run = new SubathonPromptRun { + LinkedPrompt = prompt, LinkedSet = set, Status = status, ActionId = actionId, + ExpiresAt = DateTime.Now.AddMinutes(5) + }; + db.SubathonPromptRuns.Add(run); + await db.SaveChangesAsync(ct); + return run; + } + + async Task Reload(Guid id) { + await using var db = new AppDbContext(options); + return await db.SubathonPromptRuns.AsNoTracking().FirstAsync(r => r.Id == id, ct); + } + + var announced = new ConcurrentQueue(); + SubathonEvents.PromptRunActionStatusChanged += r => announced.Enqueue(r.ActionStatus); + + SubathonPromptRun expired = await AddRun(SubathonPromptRunStatus.Expired, action.Id); + Assert.Equal(ActionRunResult.Skipped, await service.RunPromptActionAsync(expired.Id)); + SubathonPromptRun plain = await AddRun(SubathonPromptRunStatus.Completed, null); + Assert.Equal(ActionRunResult.Skipped, await service.RunPromptActionAsync(plain.Id)); + + SubathonPromptRun done = await AddRun(SubathonPromptRunStatus.Completed, action.Id); + Assert.Equal(ActionRunResult.Done, await service.RunPromptActionAsync(done.Id)); + Assert.Equal(["confetti", "bell"], runner.Ran.ToArray()); + Assert.Equal(SubathonPromptActionStatus.Done, (await Reload(done.Id)).ActionStatus); + Assert.Equal([SubathonPromptActionStatus.Running, SubathonPromptActionStatus.Done], announced.ToArray()); + + Assert.Equal(ActionRunResult.Skipped, await service.RunPromptActionAsync(done.Id)); + Assert.Equal(ActionRunResult.Skipped, await service.RunPromptActionAsync(done.Id, true)); + Assert.Equal(2, runner.Ran.Count); + + runner.FailOnce.Add("bell"); + SubathonPromptRun failed = await AddRun(SubathonPromptRunStatus.Completed, action.Id); + SubathonEvents.RaisePromptRunUpdate(failed, null); + for (var i = 0; i < 100 && (await Reload(failed.Id)).ActionStatus != SubathonPromptActionStatus.Failed; i++) + await Task.Delay(20, ct); + SubathonPromptRun afterFail = await Reload(failed.Id); + Assert.Equal(SubathonPromptActionStatus.Failed, afterFail.ActionStatus); + Assert.NotNull(afterFail.ActionProgress); + + Assert.Equal(ActionRunResult.Done, await service.RunPromptActionAsync(failed.Id, true)); + Assert.Equal(["confetti", "bell", "confetti", "bell"], runner.Ran.ToArray()); + SubathonPromptRun retried = await Reload(failed.Id); + Assert.Equal((SubathonPromptActionStatus.Done, (string?)null), (retried.ActionStatus, retried.ActionProgress)); + + SubathonPromptRun missing = await AddRun(SubathonPromptRunStatus.Completed, Guid.NewGuid()); + Assert.Equal(ActionRunResult.Skipped, await service.RunPromptActionAsync(missing.Id)); + Assert.Equal(SubathonPromptActionStatus.Failed, (await Reload(missing.Id)).ActionStatus); + + Assert.True(await service.SetCustomActionEnabledAsync(action.Id, false)); + SubathonPromptRun off = await AddRun(SubathonPromptRunStatus.Completed, action.Id); + Assert.Equal(ActionRunResult.Skipped, await service.RunPromptActionAsync(off.Id)); + Assert.Equal(SubathonPromptActionStatus.Failed, (await Reload(off.Id)).ActionStatus); + Assert.True(await service.SetCustomActionEnabledAsync(action.Id, true)); + Assert.Equal(ActionRunResult.Done, await service.RunPromptActionAsync(off.Id, true)); + Assert.Equal(6, runner.Ran.Count); + } + + [Fact] + public async Task RunActionSteps_RunOthers_SkipTurnedOffOnes_AndStopRunningInCircles() { + (ActionService service, FakeRunner runner, _, SqliteConnection conn) = await SetupAsync(); + await using SqliteConnection __ = conn; + + var child = new CustomAction { Name = "Child", Graph = ActionGraph.Sequence(Hotkey("child")) }; + await service.SaveCustomActionAsync(child); + + ActionStep Step(ActionStepType type, ActionOperation op, CustomAction target) { + return new ActionStep { Type = type, Operation = op, Target = target.Id.ToString(), TargetName = target.Name }; + } + + var parent = new CustomAction { + Name = "Parent", + Graph = ActionGraph.Sequence(Step(ActionStepType.RunAction, ActionOperation.Run, child), Hotkey("after")) + }; + Assert.Equal(ActionRunResult.Done, await service.RunManuallyAsync(parent)); + Assert.Equal(["child", "after"], runner.Ran.ToArray()); + + runner.Ran.Clear(); + Assert.Equal(ActionRunResult.Done, await service.RunAsync(Guid.NewGuid(), + ActionGraph.Sequence(Step(ActionStepType.SetActionEnabled, ActionOperation.Toggle, child)), + new ActionContext(SubathonEventSource.WheelSpin, "test", "toggle"))); + Assert.True(service.GetCustomAction(child.Id)!.Disabled); + + Assert.Equal(ActionRunResult.Done, await service.RunManuallyAsync(parent)); + Assert.Equal(["after"], runner.Ran.ToArray()); + Assert.Null(service.ResolveGraph(WheelSpinActionType.CustomAction, child.Id.ToString())); + + Assert.True(await service.SetCustomActionEnabledAsync(child.Id, true)); + Assert.Equal(child.Id, service.ResolveActionTarget(new ActionStep { + Type = ActionStepType.RunAction, Target = Guid.NewGuid().ToString(), TargetName = "child" + })?.Id); + + var loop = new CustomAction { Name = "Loop" }; + loop.Graph = ActionGraph.Sequence(Hotkey("loop"), Step(ActionStepType.RunAction, ActionOperation.Run, loop)); + loop.Graph.OnRepeat = ActionRepeatMode.Parallel; + await service.SaveCustomActionAsync(loop); + runner.Ran.Clear(); + Assert.Equal(ActionRunResult.Paused, await service.RunManuallyAsync(service.GetCustomAction(loop.Id)!)); + Assert.Equal(ActionService.MaxActionDepth + 1, runner.Ran.Count); + } + + [Fact] + public void JsonPaths_ReadTextNumbersAndNesting_AndAnythingMissingIsEmpty() { + const string json = """{"name":"Bob","n":2.5,"ok":true,"list":[{"a":"x"},{"a":"y"}],"obj":{"k":[1,2]}}"""; + Assert.Equal("Bob", ActionStepTypeHelper.ReadJsonPath(json, ".name")); + Assert.Equal("2.5", ActionStepTypeHelper.ReadJsonPath(json, ".n")); + Assert.Equal("true", ActionStepTypeHelper.ReadJsonPath(json, ".ok")); + Assert.Equal("y", ActionStepTypeHelper.ReadJsonPath(json, ".list[1].a")); + Assert.Equal("[1,2]", ActionStepTypeHelper.ReadJsonPath(json, ".obj.k")); + Assert.Equal("", ActionStepTypeHelper.ReadJsonPath(json, ".list[5].a")); + Assert.Equal("", ActionStepTypeHelper.ReadJsonPath(json, ".nope")); + Assert.Equal("", ActionStepTypeHelper.ReadJsonPath("not json", ".a")); + + Assert.True(ActionStepTypeHelper.Compare("10", ActionOperation.MoreThan, "9")); + Assert.False(ActionStepTypeHelper.Compare("10", ActionOperation.MoreThan, "9x")); + Assert.True(ActionStepTypeHelper.Compare("True", ActionOperation.IsEqual, "true")); + Assert.True(ActionStepTypeHelper.Compare(" ", ActionOperation.IsEmpty, "")); + + Assert.False(ActionStep.IsValidOutputName("points")); + Assert.False(ActionStep.IsValidOutputName("secret")); + Assert.True(ActionStep.IsValidOutputName("resp_2")); + } + + private sealed class FakeRunner : IActionStepRunner { + public ConcurrentQueue Ran { get; } = new(); + public HashSet FailOnce { get; } = []; + public string? LastUser { get; private set; } + public string? LastBody { get; private set; } + + public Dictionary SetVariables { get; } = []; + + public IReadOnlyCollection StepTypes { get; } = + [ActionStepType.VtsHotkey, ActionStepType.MixItUpCommand]; + + public Task RunStepAsync(ActionStep step, ActionContext ctx, ActionRunProgress progress, + CancellationToken ct) { + lock (FailOnce) { + if (FailOnce.Remove(step.Target)) return Task.FromResult(false); + } + + LastUser = ctx.User; + LastBody = step.Body; + if (SetVariables.TryGetValue(step.Target, out (string Name, string Value) set)) + progress.SetVariable(set.Name, set.Value); + Ran.Enqueue(step.Target); + return Task.FromResult(true); + } + } +} \ No newline at end of file diff --git a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs index 4106ca1b..70d19d3d 100644 --- a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs @@ -1317,6 +1317,37 @@ public async Task DonationCrossingGoal_RaisesGoalCompletedOnce() { await conn.CloseAsync(); } + [Fact] + public async Task PointsJumpPastMultipleGoals_RaisesEachCompletedGoalInOrder() { + (EventService service, DbContextOptions options, SqliteConnection conn) = + await SetupServiceWithDb(100, false); + await using (var db = new AppDbContext(options)) { + SubathonGoalSet set = await db.SubathonGoalSets.FirstAsync(TestContext.Current.CancellationToken); + db.SubathonGoals.AddRange( + new SubathonGoal { Text = "C", Points = 150, GoalSetId = set.Id }, + new SubathonGoal { Text = "A", Points = 110, GoalSetId = set.Id }, + new SubathonGoal { Text = "B", Points = 120, GoalSetId = set.Id }, + new SubathonGoal { Text = "D", Points = 200, GoalSetId = set.Id }); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var completed = new List(); + SubathonEvents.SubathonGoalCompleted += (goal, _) => { + lock (completed) completed.Add(goal.Text); + }; + + (bool processed, _) = await service.ProcessSubathonEvent(new SubathonEvent { + Id = Guid.NewGuid(), EventType = SubathonEventType.Command, + Command = SubathonCommandType.AddPoints, PointsValue = 50 + }); + + Assert.True(processed); + Assert.Equal(["A", "B", "C"], completed); + + await service.StopAsync(TestContext.Current.CancellationToken); + await conn.CloseAsync(); + } + [Fact] public async Task CancelledOrder_RemovesTrackedEventAndWarns() { (EventService service, DbContextOptions options, SqliteConnection conn) = @@ -1431,7 +1462,7 @@ public async Task Command_SubtractMoney_NegatesValue() { SubathonEvent? saved = await db.SubathonEvents.FirstOrDefaultAsync(e => e.Id == ev.Id, TestContext.Current.CancellationToken); Assert.NotNull(saved); - Assert.True(double.Parse(saved.Value) < 0); + Assert.True(Utils.ParseAmount(saved.Value) < 0); await conn.CloseAsync(); } diff --git a/SubathonManager.Tests/SubathonManager.Tests.csproj b/SubathonManager.Tests/SubathonManager.Tests.csproj index 8ea47e00..7c348fee 100644 --- a/SubathonManager.Tests/SubathonManager.Tests.csproj +++ b/SubathonManager.Tests/SubathonManager.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/SubathonManager.UI/App.axaml b/SubathonManager.UI/App.axaml index ea095281..9a3b3d8d 100644 --- a/SubathonManager.UI/App.axaml +++ b/SubathonManager.UI/App.axaml @@ -37,6 +37,7 @@ + diff --git a/SubathonManager.UI/App.axaml.cs b/SubathonManager.UI/App.axaml.cs index 3a5b4f76..40a6c61b 100644 --- a/SubathonManager.UI/App.axaml.cs +++ b/SubathonManager.UI/App.axaml.cs @@ -91,6 +91,10 @@ public override void OnFrameworkInitializationCompleted() { config.GetBool($"{orderSource.GetSource()}", $"{orderSource.ToString()?.Split("Order")[0]}.CommissionAsDonation", true); + Utils.DonationSettings[nameof(SubathonEventType.PatreonPledge)] = + config.GetBool(nameof(SubathonEventSource.Patreon), + $"{SubathonEventType.PatreonPledge}.CommissionAsDonation"); + _currencyVal = config.Get("Currency", "Primary", "USD")!; SetThemeVariant(config); @@ -136,7 +140,9 @@ public override void OnFrameworkInitializationCompleted() { var window = new MainWindow(); desktop.MainWindow = window; - window.Closing += (_, _) => window.CloseEditor(); + window.Closing += (_, e) => { + if (!window.CloseEditor()) e.Cancel = true; + }; desktop.ShutdownMode = ShutdownMode.OnMainWindowClose; @@ -200,11 +206,15 @@ private void OnActivationReceived(ActivationRequest request) { case ActivationKind.SmwFile: Utils.PendingWidgetPackImportPath = request.Payload; break; + case ActivationKind.SmaFile: + Utils.PendingActionImportPath = request.Payload; + break; case ActivationKind.OAuth: AppServices.Provider.GetRequiredService().HandleCallback(request.Payload); break; default: - _logger?.LogDebug("Activation received with no recognised payload: {Payload}", request.Payload); + if (_logger?.IsEnabled(LogLevel.Debug) ?? false) + _logger?.LogDebug("Activation received with no recognised payload: {Payload}", request.Payload); break; } @@ -432,6 +442,15 @@ private void ConfigChanged(object sender, FileSystemEventArgs e) { Utils.DonationSettings[$"{orderSource.ToString()?.Split("Order")[0]}"] = asDonation; } + bool patreonAsDonation = + config.GetBool(nameof(SubathonEventSource.Patreon), + $"{SubathonEventType.PatreonPledge}.CommissionAsDonation"); + if (!Utils.DonationSettings.TryGetValue(nameof(SubathonEventType.PatreonPledge), out bool hadPatreon) || + hadPatreon != patreonAsDonation) { + optionToggled = true; + Utils.DonationSettings[nameof(SubathonEventType.PatreonPledge)] = patreonAsDonation; + } + if (currencyChanged || optionToggled) Task.Run(async () => { await using AppDbContext db = await _factory!.CreateDbContextAsync(); @@ -485,7 +504,7 @@ private async Task SetupSubathonCurrencyData(AppDbContext db, bool? optionToggle if (string.IsNullOrWhiteSpace(ev.Currency)) continue; string value = ev.Value; string? curr = ev.Currency; - if (ev.EventType.IsOrder()) { + if (ev.EventType.IsOrder() || ev.EventType == SubathonEventType.PatreonPledge) { string[] parts = ev.SecondaryValue.Split('|'); if (parts.Length < 2 || !Utils.TryParseAmount(parts[0], out _)) continue; value = parts[0]; diff --git a/SubathonManager.UI/Controls/SymIcon.cs b/SubathonManager.UI/Controls/SymIcon.cs index b7ebbfa3..afc30288 100644 --- a/SubathonManager.UI/Controls/SymIcon.cs +++ b/SubathonManager.UI/Controls/SymIcon.cs @@ -1,6 +1,7 @@ using Avalonia; using FluentIcons.Avalonia; using FluentIcons.Common; +using SubathonManager.Core; namespace SubathonManager.UI.Controls; @@ -28,7 +29,7 @@ private void ApplyGlyph(string? glyph) { if (Enum.TryParse(name, out Symbol sym)) Symbol = sym; - if (double.TryParse(sizeStr, out double size) && size > 0) + if (Utils.TryParseAmount(sizeStr, out double size) && size > 0) FontSize = size; } } \ No newline at end of file diff --git a/SubathonManager.UI/EditRouteWindow.Handlers.cs b/SubathonManager.UI/EditRouteWindow.Handlers.cs index 5a93136c..e25714be 100644 --- a/SubathonManager.UI/EditRouteWindow.Handlers.cs +++ b/SubathonManager.UI/EditRouteWindow.Handlers.cs @@ -371,8 +371,15 @@ private void ReloadVars_Click(object? sender, RoutedEventArgs e) { } private async void SaveWidgetButton_Click(object? sender, RoutedEventArgs e) { + if (!await SaveSelectedWidgetAsync()) return; + SaveWidgetButton.Content = "Saved!"; + await Task.Delay(1500); + SaveWidgetButton.Content = "Save"; + } + + private async Task SaveSelectedWidgetAsync() { try { - if (_selectedWidget == null) return; + if (_selectedWidget == null) return true; var widgetHelper = new WidgetEntityHelper(_factory, null); widgetHelper.SyncCssVariables(_selectedWidget); @@ -382,11 +389,11 @@ private async void SaveWidgetButton_Click(object? sender, RoutedEventArgs e) { _selectedWidget.Name = WidgetNameBox.Text ?? string.Empty; _selectedWidget.Width = int.TryParse(WidgetWidthBox.Text, out int w) ? w : _selectedWidget.Width; _selectedWidget.Height = int.TryParse(WidgetHeightBox.Text, out int h) ? h : _selectedWidget.Height; - _selectedWidget.X = float.TryParse(WidgetXBox.Text, out float x) ? x : _selectedWidget.X; - _selectedWidget.Y = float.TryParse(WidgetYBox.Text, out float y) ? y : _selectedWidget.Y; - _selectedWidget.ScaleX = float.TryParse(WidgetScaleXBox.Text, out float sx) ? sx : + _selectedWidget.X = Utils.TryParseAmount(WidgetXBox.Text, out double x) ? (float)x : _selectedWidget.X; + _selectedWidget.Y = Utils.TryParseAmount(WidgetYBox.Text, out double y) ? (float)y : _selectedWidget.Y; + _selectedWidget.ScaleX = Utils.TryParseAmount(WidgetScaleXBox.Text, out double sx) ? (float)sx : _selectedWidget.ScaleX == 0 ? 1 : _selectedWidget.ScaleX; - _selectedWidget.ScaleY = float.TryParse(WidgetScaleYBox.Text, out float sy) ? sy : + _selectedWidget.ScaleY = Utils.TryParseAmount(WidgetScaleYBox.Text, out double sy) ? (float)sy : _selectedWidget.ScaleY == 0 ? 1 : _selectedWidget.ScaleY; foreach (CssVariable cssVar in _editingCssVars) { @@ -426,12 +433,11 @@ private async void SaveWidgetButton_Click(object? sender, RoutedEventArgs e) { await db.Entry(_selectedWidget).ReloadAsync(); UpdateSaveButtonBorder(SaveButtonBorder, false); - SaveWidgetButton.Content = "Saved!"; - await Task.Delay(1500); - SaveWidgetButton.Content = "Save"; + return true; } catch (Exception ex) { _logger?.LogError(ex, "Failed to save widget"); + return false; } } @@ -1002,12 +1008,14 @@ private async Task FinishWidgetDragAsync() { } private async void SaveRouteButton_Click(object? sender, RoutedEventArgs e) { - if (_route == null) return; - await SaveCurrentRoute(); + if (_route == null || !await SaveCurrentRoute()) return; + SaveRouteButton.Content = "Saved!"; + await Task.Delay(1500); + SaveRouteButton.Content = "Save"; } - private async Task SaveCurrentRoute() { - if (_route == null) return; + private async Task SaveCurrentRoute() { + if (_route == null) return true; try { await SaveAllPendingWidgetChangesAsync(); @@ -1022,13 +1030,11 @@ private async Task SaveCurrentRoute() { UpdateWebViewScale(); OverlayEvents.RaiseOverlayRefreshRequested(_route.Id); RouteSaved?.Invoke(); - - SaveRouteButton.Content = "Saved!"; - await Task.Delay(1500); - SaveRouteButton.Content = "Save"; + return true; } catch (Exception ex) { _logger?.LogError(ex, "Failed to save overlay"); + return false; } } @@ -1092,6 +1098,58 @@ void Attach() { SuppressUnsavedChanges(Attach); } + #region GlobalVars + + private int _loadingGlobalVars; + + private async Task PopulateGlobalVarsAsync(Widget widget) { + _loadingGlobalVars++; + try { + List globals = await ServiceManager.Actions.GetGlobalsAsync(ActionStoreKind.Global); + if (_selectedWidget?.Id != widget.Id) return; + + IReadOnlyList picked = widget.GlobalVarNames; + List options = globals.Select(g => new FilterOption { + Label = g.Name, Value = g.Name, Group = $"{g.ValueType}" + }).ToList(); + options.AddRange(picked + .Where(n => !globals.Any(g => g.Name.Equals(n, StringComparison.OrdinalIgnoreCase))) + .Select(n => new FilterOption { Label = $"{n} (doesn't exist)", Value = n, Group = "Missing" })); + + GlobalVarsPicker.SetOptions(options, true); + GlobalVarsPicker.SetSelected(picked); + GlobalVarsModeBox.SelectedIndex = widget.ListensToAllGlobals ? 1 + : string.IsNullOrWhiteSpace(widget.GlobalVars) ? 0 : 2; + GlobalVarsPicker.IsVisible = GlobalVarsModeBox.SelectedIndex == 2; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "Could not load globals for widget editor"); + } + finally { + _loadingGlobalVars--; + } + } + + private void GlobalVarsMode_Changed(object? sender, SelectionChangedEventArgs e) { + ApplyGlobalVars(); + } + + private void ApplyGlobalVars() { + GlobalVarsPicker.IsVisible = GlobalVarsModeBox.SelectedIndex == 2; + if (_loadingGlobalVars > 0 || _selectedWidget == null) return; + + string value = GlobalVarsModeBox.SelectedIndex switch { + 1 => Widget.AllGlobals, + 2 => Widget.JoinGlobalVars(false, GlobalVarsPicker.SelectedOptions.Select(o => o.Value)), + _ => string.Empty + }; + if (value == _selectedWidget.GlobalVars) return; + _selectedWidget.GlobalVars = value; + UpdateSaveButtonBorder(SaveButtonBorder, true); + } + + #endregion + private void Value_OnChanged(object? sender, RoutedEventArgs e) { bool realChange = DirtySaveGuard.Consume(sender); if (_suppressCount > 0 || !realChange) return; @@ -1194,8 +1252,9 @@ private void OpacityBox_Loaded(object? sender, RoutedEventArgs e) { } private static bool TryParseSliderValue(string? text, out float value) { - return float.TryParse((text ?? "").Trim().TrimEnd('%'), NumberStyles.Float, - CultureInfo.InvariantCulture, out value); + bool ok = Utils.TryParseAmount((text ?? "").Trim().TrimEnd('%'), out double parsed); + value = (float)parsed; + return ok; } private static string FormatSliderBoxText(CssVariable cssVar, float value) { diff --git a/SubathonManager.UI/EditRouteWindow.axaml b/SubathonManager.UI/EditRouteWindow.axaml index a725c437..dc813063 100644 --- a/SubathonManager.UI/EditRouteWindow.axaml +++ b/SubathonManager.UI/EditRouteWindow.axaml @@ -691,9 +691,20 @@ - + + + + + + + + + + + diff --git a/SubathonManager.UI/EditRouteWindow.axaml.cs b/SubathonManager.UI/EditRouteWindow.axaml.cs index f4589cbe..82c902ec 100644 --- a/SubathonManager.UI/EditRouteWindow.axaml.cs +++ b/SubathonManager.UI/EditRouteWindow.axaml.cs @@ -8,6 +8,8 @@ using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Platform.Storage; +using Avalonia.Layout; +using Avalonia.Media; using Avalonia.Threading; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -48,6 +50,8 @@ public partial class EditRouteWindow : Window { private string _editUrl = string.Empty; private bool _hasPendingCssChanges; private bool _hasPendingJsChanges; + private bool _hasPendingWidgetChanges; + private bool _closeConfirmed; private string _lastFolder = string.Empty; private bool _loadedWebView; private Route? _route; @@ -68,6 +72,7 @@ public EditRouteWindow(Guid routeId) { WindowIcons.Apply(this); EditorRouteId = routeId; WidgetsList.ItemsSource = _widgets; + GlobalVarsPicker.SelectionChanged += (_, _) => ApplyGlobalVars(); BrowserEditorButton.IsVisible = OperatingSystem.IsLinux(); WebViewWarningButton.IsVisible = OperatingSystem.IsLinux(); UiHelpers.EnableClickAwayUnfocus(this); @@ -106,6 +111,7 @@ public EditRouteWindow(Guid routeId) { }); Loaded += EditRouteWindow_Loaded; + Closing += Window_Closing; ObsConnected = ServiceManager.OBS.Connected; IntegrationEvents.ConnectionUpdated += OnObsConnectionUpdated; Closed += (_, _) => { @@ -359,6 +365,7 @@ private void PopulateWidgetEditor(Widget? widget) { SubscribeCssVarChanges(); PopulateJsVars(); + if (!isAsset) _ = PopulateGlobalVarsAsync(widget); bool hasStash = _unsavedCssVars.ContainsKey(widget.Id) || _unsavedJsVars.ContainsKey(widget.Id); _hasPendingCssChanges = _unsavedCssVars.ContainsKey(widget.Id); _hasPendingJsChanges = _unsavedJsVars.ContainsKey(widget.Id); @@ -366,6 +373,7 @@ private void PopulateWidgetEditor(Widget? widget) { Dispatcher.UIThread.Post(() => { _suppressCount--; + _hasPendingWidgetChanges = realDirty; UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, realDirty); }, DispatcherPriority.Background); } @@ -635,10 +643,14 @@ private async Task ImportSingleWidgetAsync(string path, AppDbContext db, WidgetE newWidget.Width = metadata.Width > 0 ? metadata.Width : 400; newWidget.Height = metadata.Height > 0 ? metadata.Height : 400; newWidget.DocsUrl = metadata.Url; + newWidget.GlobalVars = metadata.Globals?.ToWidgetValue() ?? string.Empty; db.Widgets.Add(newWidget); await db.SaveChangesAsync(); + if (metadata.Globals is { Vars.Count: > 0 } wanted) + await ServiceManager.Actions.EnsureTypedGlobalsAsync(wanted.Vars, $"Widget \"{newWidget.Name}\""); + (List jsVars, _, _) = helper.LoadNewJsVariables(newWidget, metadata); List allVarTypes = jsVars.Select(j => j.Type).Distinct().ToList(); @@ -797,6 +809,78 @@ private void ApplyWebViewBgToggle() { } + private bool HasUnsavedChanges() { + if (_hasPendingWidgetChanges || _hasPendingCssChanges || _hasPendingJsChanges) return true; + if (_unsavedCssVars.Count > 0 || _unsavedJsVars.Count > 0) return true; + if (_route == null) return false; + if ((RouteNameBox.Text ?? string.Empty).Trim() != _route.Name) return true; + if (int.TryParse(RouteWidthBox.Text, out int w) && w != _route.Width) return true; + return int.TryParse(RouteHeightBox.Text, out int h) && h != _route.Height; + } + + private async void Window_Closing(object? sender, WindowClosingEventArgs e) { + if (_closeConfirmed || !HasUnsavedChanges()) return; + e.Cancel = true; + + bool? save = await ShowUnsavedChangesDialogAsync(); + if (save == null) return; + if (save == true) { + if (_hasPendingWidgetChanges && !await SaveSelectedWidgetAsync()) return; + if (!await SaveCurrentRoute()) return; + } + + _closeConfirmed = true; + Close(); + } + + private async Task ShowUnsavedChangesDialogAsync() { + var dialog = new Window { + Title = "Unsaved changes", + SizeToContent = SizeToContent.WidthAndHeight, + CanResize = false, + ShowInTaskbar = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner + }; + dialog.SetDynamicResource(BackgroundProperty, "AppGeneralBackground"); + WindowIcons.Apply(dialog); + + bool? result = null; + Button MakeButton(string text, bool? value, string? cls = null) { + var button = new Button { Content = text, MinWidth = 90, Height = 32 }; + if (cls != null) button.Classes.Add(cls); + button.Click += (_, _) => { + result = value; + dialog.Close(); + }; + return button; + } + + var buttons = new StackPanel { + Orientation = Orientation.Horizontal, + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Right + }; + buttons.Children.Add(MakeButton("Cancel", null)); + buttons.Children.Add(MakeButton("Discard", false, "danger")); + buttons.Children.Add(MakeButton("Save", true, "accent")); + + dialog.Content = new StackPanel { + Margin = new Thickness(24, 16), + Spacing = 16, + MaxWidth = 420, + Children = { + new TextBlock { + Text = $"Save the changes to \"{(RouteNameBox.Text ?? "").Trim()}\" before closing?", + TextWrapping = TextWrapping.Wrap + }, + buttons + } + }; + + await dialog.ShowDialog(this); + return result; + } + protected override void OnClosed(EventArgs e) { WidgetEvents.WidgetPositionUpdated -= OnWidgetPositionUpdated; WidgetEvents.WidgetScaleUpdated -= OnWidgetScaleUpdated; @@ -818,6 +902,7 @@ protected override void OnClosed(EventArgs e) { } private void UpdateSaveButtonBorder(Border border, bool hasPendingChanges) { + if (border == SaveButtonBorder) _hasPendingWidgetChanges = hasPendingChanges; Dispatcher.UIThread.Post(() => UiHelpers.UpdateButtonPendingBorder(border, hasPendingChanges)); } diff --git a/SubathonManager.UI/MainWindow.ConnectionStatus.cs b/SubathonManager.UI/MainWindow.ConnectionStatus.cs index f211eab6..affc5403 100644 --- a/SubathonManager.UI/MainWindow.ConnectionStatus.cs +++ b/SubathonManager.UI/MainWindow.ConnectionStatus.cs @@ -136,12 +136,13 @@ private MenuItem BuildSourceItem(SubathonEventSource source, List c.Status); IBrush brush = up ? StatusUpBrush @@ -262,6 +263,22 @@ private MenuItem BuildObsItem(List connections) { return item; } + private MenuItem BuildStreamerBotItem(List connections) { + IntegrationConnection? extension = connections.FirstOrDefault(c => c.Service == "Socket"); + IntegrationConnection? http = connections.FirstOrDefault(c => c.Service == "Http"); + + var item = new MenuItem { + Icon = MakeStatusDot(GetAggregateBrush(connections)), + StaysOpenOnClick = true + }; + SetParentHeader(item, SubathonEventSource.StreamerBot.GetDescription()); + item.Items.Add(MakeLeafItem("Extension", + extension?.Status == true ? StatusUpBrush : StatusDownBrush, null, SubathonEventSource.StreamerBot)); + item.Items.Add(MakeLeafItem("HTTP API", LeafBrush(http), + http is { Configured: true } ? null : "Not enabled", SubathonEventSource.StreamerBot)); + return item; + } + private static string ShowableDetail(IntegrationConnection connection) { if (string.IsNullOrWhiteSpace(connection.Name)) return ""; if (string.Equals(connection.Name, "None", StringComparison.OrdinalIgnoreCase)) return ""; diff --git a/SubathonManager.UI/MainWindow.Multiplier.cs b/SubathonManager.UI/MainWindow.Multiplier.cs index 538bdf72..31d771a6 100644 --- a/SubathonManager.UI/MainWindow.Multiplier.cs +++ b/SubathonManager.UI/MainWindow.Multiplier.cs @@ -45,7 +45,7 @@ private void TogglePowerMultiplier_Click(object? sender, RoutedEventArgs e) { } TimeSpan duration = Utils.ParseDurationString(MultiplierDurationInput.Text ?? ""); - if (!double.TryParse(MultiplierAmtInput.Text, out double parsedAmt) || parsedAmt <= 0) return; + if (!Utils.TryParseAmount(MultiplierAmtInput.Text, out double parsedAmt) || parsedAmt <= 0) return; string durationStr = duration == TimeSpan.Zero ? "x" : ((int)duration.TotalSeconds).ToString(); var dataStr = $"{parsedAmt}|{durationStr}s|{applyPts}|{applyTime}"; diff --git a/SubathonManager.UI/MainWindow.Routes.cs b/SubathonManager.UI/MainWindow.Routes.cs index 54f116c6..101ce489 100644 --- a/SubathonManager.UI/MainWindow.Routes.cs +++ b/SubathonManager.UI/MainWindow.Routes.cs @@ -14,6 +14,7 @@ using SubathonManager.Data; using SubathonManager.Data.Overlays; using SubathonManager.Data.Widgets; +using SubathonManager.UI.Services; using SubathonManager.UI.UiUtils; using SubathonManager.UI.Views; @@ -234,6 +235,10 @@ private async Task ImportRouteFromFile(string filePath) { return false; } + if (result.RequiredGlobals.Count > 0) + await ServiceManager.Actions.EnsureTypedGlobalsAsync(result.RequiredGlobals, + $"Overlay \"{manifest.Name}\""); + if (!result.HasAnythingNew) { _logger?.LogInformation("Import: everything already exists, nothing to add"); return false; @@ -282,8 +287,9 @@ private async Task ImportRouteFromFile(string filePath) { } } - public void CloseEditor() { - if (_editWindow != null) _editWindow.Close(); + public bool CloseEditor() { + _editWindow?.Close(); + return _editWindow == null; } internal void OpenRouteEditor(Route route) { @@ -293,14 +299,18 @@ internal void OpenRouteEditor(Route route) { return; } - _editWindow.Close(); + if (!CloseEditor()) { + _editWindow.Activate(); + return; + } } - _editWindow = new EditRouteWindow(route.Id); + var editor = new EditRouteWindow(route.Id); + _editWindow = editor; _editWindow.RouteSaved += LoadRoutes; _editWindow.Closed += (_, _) => { LoadRoutes(); - _editWindow = null; + if (_editWindow == editor) _editWindow = null; }; UiHelpers.CenterOver(_editWindow, this); diff --git a/SubathonManager.UI/MainWindow.SubathonTimer.cs b/SubathonManager.UI/MainWindow.SubathonTimer.cs index ce4d59c1..c6ac3b29 100644 --- a/SubathonManager.UI/MainWindow.SubathonTimer.cs +++ b/SubathonManager.UI/MainWindow.SubathonTimer.cs @@ -281,7 +281,7 @@ private void SubtractMoney_Click(object? sender, RoutedEventArgs e) { } private void AdjustSubathonMoneyBy(int direction) { - if (!double.TryParse(AdjustSubathonMoney.Text, out double parsedVal) || parsedVal <= 0) return; + if (!Utils.TryParseAmount(AdjustSubathonMoney.Text, out double parsedVal) || parsedVal <= 0) return; SubathonCommandType cmd = direction > 0 ? SubathonCommandType.AddMoney : SubathonCommandType.SubtractMoney; RaiseCommand(new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), diff --git a/SubathonManager.UI/MainWindow.axaml b/SubathonManager.UI/MainWindow.axaml index c41ea338..104f1112 100644 --- a/SubathonManager.UI/MainWindow.axaml +++ b/SubathonManager.UI/MainWindow.axaml @@ -6,6 +6,7 @@ xmlns:views="using:SubathonManager.UI.Views" xmlns:prompts="using:SubathonManager.UI.Views.Prompts" xmlns:wheel="using:SubathonManager.UI.Views.WheelSpin" + xmlns:actions="using:SubathonManager.UI.Views.Actions" xmlns:ctl="using:SubathonManager.UI.Controls" xmlns:v="using:SubathonManager.UI.Validation" xmlns:schedule="clr-namespace:SubathonManager.UI.Views.Schedule" @@ -517,6 +518,9 @@ + + + diff --git a/SubathonManager.UI/MainWindow.axaml.cs b/SubathonManager.UI/MainWindow.axaml.cs index f4fe58a7..985cbb73 100644 --- a/SubathonManager.UI/MainWindow.axaml.cs +++ b/SubathonManager.UI/MainWindow.axaml.cs @@ -2,8 +2,14 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using FluentAvalonia.UI.Controls; using Microsoft.Extensions.Logging; using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; using SubathonManager.Data.Widgets; using SubathonManager.UI.Services; using SubathonManager.UI.UiUtils; @@ -26,6 +32,7 @@ public MainWindow() { InitConnectionStatus(); InitHome(); InitOverlays(); + ServiceManager.Actions.GlobalTypeConflictsFound += OnGlobalTypeConflicts; RecentEventsList.HiddenTypesChanged += UpdateRecentEventsFilterTip; UpdateRecentEventsFilterTip(); @@ -39,6 +46,7 @@ public MainWindow() { await MaybeShowTelemetryPromptAsync(); await ImportPendingOverlayAsync(); await CollectPendingWidgetPackAsync(); + await ImportPendingActionAsync(); }; } @@ -72,9 +80,68 @@ public void HandlePendingActivation(ActivationKind kind) { case ActivationKind.SmwFile: _ = CollectPendingWidgetPackAsync(); break; + case ActivationKind.SmaFile: + _ = ImportPendingActionAsync(); + break; } } + private async Task ImportPendingActionAsync() { + string? path = Utils.PendingActionImportPath; + Utils.PendingActionImportPath = null; + if (string.IsNullOrWhiteSpace(path)) return; + + try { + if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { + using var client = new HttpClient(); + string json = await client.GetStringAsync(path); + path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}{CustomAction.FileExtension}"); + await File.WriteAllTextAsync(path, json); + } + + CustomAction? added = await ServiceManager.Actions.AddCustomActionCopyAsync(path); + MainWindowTabs.SelectedItem = ActionsTabItem; + ActionsPage.ShowAdded(added, path); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "Failed to add custom action {Path}", path); + } + } + + private void OnGlobalTypeConflicts(string source, IReadOnlyList conflicts) { + Dispatcher.UIThread.Post(async void () => { + try { + string lines = string.Join(Environment.NewLine, conflicts.Select(c => + $"- {c.Name}: is {c.Existing.GetLabel()}, expects {c.Wanted.GetLabel()}")); + + var dialog = new FAContentDialog { + Title = "Global type mismatch", + PrimaryButtonText = "Change types", + CloseButtonText = "Keep current types", + DefaultButton = FAContentDialogButton.Close, + Content = new TextBlock { + Text = $"{source} expects these globals to be a different type:{Environment.NewLine}" + + $"{Environment.NewLine}{lines}{Environment.NewLine}{Environment.NewLine}" + + "Changing a type converts the value where it can, otherwise it resets it. " + + "Please verify or rename globals according to your needs.", + TextWrapping = TextWrapping.Wrap, + Width = 380, + Margin = new Thickness(4) + } + }; + + Window owner = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime) + ?.Windows.FirstOrDefault(w => w.IsActive) ?? this; + if (await dialog.ShowAsync(owner) != FAContentDialogResult.Primary) return; + foreach (GlobalTypeConflict conflict in conflicts) + await ServiceManager.Actions.SetGlobalTypeAsync(conflict.Name, conflict.Wanted); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "Could not resolve global type mismatches from {Source}", source); + } + }); + } + private async Task ImportPendingOverlayAsync() { string? path = Utils.PendingOverlayImportPath; if (string.IsNullOrWhiteSpace(path)) return; diff --git a/SubathonManager.UI/Platform/IPlatformIntegration.cs b/SubathonManager.UI/Platform/IPlatformIntegration.cs index 3d8053e5..04158c3e 100644 --- a/SubathonManager.UI/Platform/IPlatformIntegration.cs +++ b/SubathonManager.UI/Platform/IPlatformIntegration.cs @@ -13,5 +13,6 @@ public enum ActivationKind { Unknown, SmoFile, SmwFile, - OAuth + OAuth, + SmaFile } \ No newline at end of file diff --git a/SubathonManager.UI/Platform/PosixPlatformIntegration.cs b/SubathonManager.UI/Platform/PosixPlatformIntegration.cs index 68439173..1561398c 100644 --- a/SubathonManager.UI/Platform/PosixPlatformIntegration.cs +++ b/SubathonManager.UI/Platform/PosixPlatformIntegration.cs @@ -8,6 +8,7 @@ public sealed class PosixPlatformIntegration : PlatformIntegrationBase { private const string OverlayMime = "application/x-subathonmanager-overlay"; private const string WidgetMime = "application/x-subathonmanager-widget"; private const string CollectionMime = "application/x-subathonmanager-widget-collection"; + private const string ActionMime = "application/x-subathonmanager-action"; private const string DesktopFileName = "subathonmanager.desktop"; private const string MimePackageFileName = "subathonmanager-overlay.xml"; private const string IconName = "subathonmanager"; @@ -64,7 +65,7 @@ private static void RegisterLinux() { "NoDisplay=false\n" + "Categories=Utility;\n" + "StartupWMClass=SubathonManager\n" + - $"MimeType={OverlayMime};{WidgetMime};{CollectionMime};{SchemeMime};\n"; + $"MimeType={OverlayMime};{WidgetMime};{CollectionMime};{ActionMime};{SchemeMime};\n"; string mimeContent = "\n" + @@ -84,6 +85,11 @@ private static void RegisterLinux() { $" \n" + " \n" + " \n" + + $" \n" + + " Subathon Manager Action\n" + + $" \n" + + " \n" + + " \n" + "\n"; bool desktopChanged = WriteIfChanged(desktopPath, desktopContent); @@ -101,6 +107,7 @@ private static void RegisterLinux() { Run("xdg-mime", "default", DesktopFileName, OverlayMime); Run("xdg-mime", "default", DesktopFileName, WidgetMime); Run("xdg-mime", "default", DesktopFileName, CollectionMime); + Run("xdg-mime", "default", DesktopFileName, ActionMime); } private static bool InstallHicolorIcons(string appDir, string dataHome) { diff --git a/SubathonManager.UI/Platform/ProtocolParser.cs b/SubathonManager.UI/Platform/ProtocolParser.cs index c7c3f0de..1a69e2ad 100644 --- a/SubathonManager.UI/Platform/ProtocolParser.cs +++ b/SubathonManager.UI/Platform/ProtocolParser.cs @@ -1,6 +1,7 @@ using System.Collections.Specialized; using System.Web; using SubathonManager.Core; +using SubathonManager.Core.Objects; namespace SubathonManager.UI.Platform; @@ -24,6 +25,11 @@ public static ActivationRequest Parse(string[] args) { return new ActivationRequest(ActivationKind.SmwFile, arg); } + if (arg.EndsWith(CustomAction.FileExtension, StringComparison.OrdinalIgnoreCase) && File.Exists(arg)) { + Utils.PendingActionImportPath = arg; + return new ActivationRequest(ActivationKind.SmaFile, arg); + } + if (!arg.StartsWith("subathonmanager://")) return new ActivationRequest(ActivationKind.Unknown, arg); @@ -48,6 +54,9 @@ public static ActivationRequest Parse(string[] args) { case ActivationKind.SmwFile: Utils.PendingWidgetPackImportPath = url; return new ActivationRequest(ActivationKind.SmwFile, url); + case ActivationKind.SmaFile: + Utils.PendingActionImportPath = url; + return new ActivationRequest(ActivationKind.SmaFile, url); default: return new ActivationRequest(ActivationKind.Unknown, arg); } @@ -63,6 +72,8 @@ private static ActivationKind ClassifyPack(string value) { if (path.EndsWith(".smw", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".smwc", StringComparison.OrdinalIgnoreCase)) return ActivationKind.SmwFile; + if (path.EndsWith(CustomAction.FileExtension, StringComparison.OrdinalIgnoreCase)) + return ActivationKind.SmaFile; return ActivationKind.Unknown; } @@ -70,6 +81,7 @@ private static ActivationKind ClassifyHost(string host) { return host.ToLowerInvariant() switch { "overlay" or "smo" => ActivationKind.SmoFile, "widget" or "widgets" or "smw" or "smwc" or "collection" => ActivationKind.SmwFile, + "action" or "actions" or "sma" => ActivationKind.SmaFile, _ => ActivationKind.Unknown }; } diff --git a/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs b/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs index 0345355d..60e79b5b 100644 --- a/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs +++ b/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs @@ -48,6 +48,15 @@ public override void RegisterFileAssociations() { "", $"\"{exePath}\" \"%1\""); EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.smwc\OpenWithProgids", "SubathonManager.WidgetCollection", ""); + + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.sma", "", "SubathonManager.Action"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Action", "", + "Subathon Manager Action"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Action\DefaultIcon", "", + $"{exePath},0"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Action\shell\open\command", "", + $"\"{exePath}\" \"%1\""); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.sma\OpenWithProgids", "SubathonManager.Action", ""); } private static void EnsureRegistryValue(string keyPath, string name, string expectedValue) { diff --git a/SubathonManager.UI/Services/ServiceManager.cs b/SubathonManager.UI/Services/ServiceManager.cs index 708c2dcc..73eaa48c 100644 --- a/SubathonManager.UI/Services/ServiceManager.cs +++ b/SubathonManager.UI/Services/ServiceManager.cs @@ -58,6 +58,7 @@ private static IServiceProvider Provider { public static ThroneService Throne => Provider.GetRequiredService(); public static PallyService Pally => Provider.GetRequiredService(); public static TiltifyService Tiltify => Provider.GetRequiredService(); + public static PatreonService Patreon => Provider.GetRequiredService(); public static DevTunnelsService DevTunnels => Provider.GetRequiredService(); public static KoFiService KoFi => Provider.GetRequiredService(); public static TwitchService Twitch => Provider.GetRequiredService(); @@ -70,6 +71,8 @@ private static IServiceProvider Provider { public static OBSService OBS => Provider.GetRequiredService(); public static VTSService VTubeStudio => Provider.GetRequiredService(); public static MixItUpService MixItUp => Provider.GetRequiredService(); + public static StreamerBotService StreamerBot => Provider.GetRequiredService(); + public static ActionService Actions => Provider.GetRequiredService(); public static WebServer Server => Provider.GetRequiredService(); @@ -79,6 +82,11 @@ private static IServiceProvider Provider { public async Task StartIntegrationsAsync() { await StartAsync(); if (FeatureFlags.VTubeStudioEnabled) await StartAsync(); + await StartAsync(); + await StartAsync(); + await StartAsync(); + await StartAsync(); // shared tunnel infrastructure; must start before webhook integrations + await StartAsync(); await StartAsync(); await StartAsync(); @@ -90,11 +98,10 @@ public async Task StartIntegrationsAsync() { await StartAsync(); await StartAsync(); await StartAsync(); - await StartAsync(); // shared tunnel infrastructure; must start before webhook integrations await StartAsync(); await StartAsync(); + await StartAsync(); await StartAsync(); - await StartAsync(); await StartAsync(); await StartAsync(); await StartAsync(); @@ -116,10 +123,13 @@ public async Task StopIntegrationsAsync() { await StopAsync(); await StopAsync(); await StopAsync(); + await StopAsync(); await StopAsync(); + await StopAsync(); await StopAsync(); await StopAsync(); await StopAsync(); + await StopAsync(); await StopAsync(); await StopAsync(); } @@ -151,7 +161,9 @@ public async Task StartAsync(CancellationToken ct = default, bool fireAndForg else await service.StartAsync(ct); _running.Add(typeof(T)); - logger?.LogDebug("{Service} started", typeof(T).Name); + + if (logger?.IsEnabled(LogLevel.Debug) ?? false) + logger?.LogDebug("{Service} started", typeof(T).Name); } finally { lk.Release(); @@ -168,7 +180,8 @@ public async Task StopAsync(CancellationToken ct = default) where T : IAppSer if (service != null) await service.StopAsync(ct); _running.Remove(typeof(T)); - logger?.LogDebug("{Service} stopped", typeof(T).Name); + if (logger?.IsEnabled(LogLevel.Debug) ?? false) + logger?.LogDebug("{Service} stopped", typeof(T).Name); } finally { lk.Release(); diff --git a/SubathonManager.UI/Services/ServiceRegistration.cs b/SubathonManager.UI/Services/ServiceRegistration.cs index 9dede738..394ff47b 100644 --- a/SubathonManager.UI/Services/ServiceRegistration.cs +++ b/SubathonManager.UI/Services/ServiceRegistration.cs @@ -32,6 +32,7 @@ public static void SetupCoreServices(this IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddHttpClient(nameof(OAuthService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); @@ -76,10 +77,14 @@ public static void AddIntegrations(this IServiceCollection services) { services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHttpClient(nameof(PatreonService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan) + .AddHttpMessageHandler(sp => + // logging handler is so client logs responses instead of null return + new PatreonService.ErrorLoggingHandler(sp.GetService>())); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(); // Stream Extensions // services.AddHttpClient(nameof(TipeeeStreamService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); @@ -90,13 +95,23 @@ public static void AddIntegrations(this IServiceCollection services) { services.AddSingleton(); // Other // + services.AddSingleton(); + services.AddSingleton(); + services.AddHttpClient(nameof(DiscordWebhookService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); - ; services.AddSingleton(); + + // External Software services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddHttpClient(nameof(MixItUpService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHttpClient(nameof(StreamerBotService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); } private static void ConfigureLogging(ILoggingBuilder builder) { diff --git a/SubathonManager.UI/SubathonManager.UI.csproj b/SubathonManager.UI/SubathonManager.UI.csproj index c50e3672..f1950212 100644 --- a/SubathonManager.UI/SubathonManager.UI.csproj +++ b/SubathonManager.UI/SubathonManager.UI.csproj @@ -48,7 +48,7 @@ - + @@ -57,6 +57,7 @@ + diff --git a/SubathonManager.UI/Validation/NumericInputBehaviour.cs b/SubathonManager.UI/Validation/NumericInputBehaviour.cs index 13233f6c..e1cc919a 100644 --- a/SubathonManager.UI/Validation/NumericInputBehaviour.cs +++ b/SubathonManager.UI/Validation/NumericInputBehaviour.cs @@ -64,6 +64,8 @@ private static bool IsAcceptable(string text, NumericMode mode) { bool allowSign = mode is NumericMode.SignedInteger or NumericMode.SignedDecimal; bool allowDecimal = mode is NumericMode.Decimal or NumericMode.SignedDecimal; + if (allowDecimal && text.Count(c => c is '.' or ',') <= 1) text = text.Replace(',', '.'); + if (text == "-") return allowSign; if (text == ".") return allowDecimal; if (text == "-.") return allowSign && allowDecimal; diff --git a/SubathonManager.UI/Views/Actions/ActionEditorViewModels.cs b/SubathonManager.UI/Views/Actions/ActionEditorViewModels.cs new file mode 100644 index 00000000..8394557d --- /dev/null +++ b/SubathonManager.UI/Views/Actions/ActionEditorViewModels.cs @@ -0,0 +1,140 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; +using Avalonia; +using Avalonia.Media; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Objects; + +namespace SubathonManager.UI.Views.Actions; + +public abstract class ActionEditorVm : INotifyPropertyChanged { + public event PropertyChangedEventHandler? PropertyChanged; + + protected void Raise([CallerMemberName] string? name = null) { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } +} + +public sealed class ActionNodeVm : ActionEditorVm { + public ActionNodeVm(ActionNode node) { + Node = node; + Input = new ActionConnectorVm(this, true); + Output = new ActionConnectorVm(this, false); + ElseOutput = new ActionConnectorVm(this, false, ActionEdge.ElsePort); + } + + public ActionNode Node { get; } + public ActionConnectorVm Input { get; } + public ActionConnectorVm Output { get; } + + public ActionConnectorVm ElseOutput { get; } + + public bool IsCondition => Node.Step.Type == ActionStepType.Condition; + public bool IsTrigger => Node.Step.Type == ActionStepType.Trigger; + public bool HasInput => !IsTrigger; + + private static readonly IBrush TriggerTint = new SolidColorBrush(Color.FromArgb(0x33, 0x1C, 0xB8, 0x96)); + private static readonly IBrush TriggerBorder = new SolidColorBrush(Color.FromRgb(0x1C, 0xB8, 0x96)); + public IBrush CardTint => IsTrigger ? TriggerTint : Brushes.Transparent; + public IBrush CardBorder => IsTrigger ? TriggerBorder : Brushes.Transparent; + public bool IgnoresErrors => Node.IgnoreErrors; + public bool IsMultiInput => HasInput && Node.Inputs == ActionInputMode.Multiple; + + public Point Location { + get => new(Node.X, Node.Y); + set { + if (Math.Abs(value.X - Node.X) < 0.1 && Math.Abs(value.Y - Node.Y) < 0.1) return; + Node.X = value.X; + Node.Y = value.Y; + Raise(); + } + } + + public string Title => IsTrigger + ? $"Trigger - {Node.Step.Trigger?.GetLabel() ?? "?"}" : $"{Node.Step.Type.GetGroup()} - {Node.Step.Type.GetLabel()}"; + public string Summary => Node.Step.Describe(); + public bool IsStart { get; private set; } + public bool IsEnd { get; private set; } + + public int Branches { get; private set; } + public bool HasBranches => Branches > 1; + public string BranchLabel => $"{Branches}b"; + + public string? Error { get; private set; } + public bool HasError => Error != null; + + public bool IsDisabled => Node.Disabled; + public double CardOpacity => Node.Disabled ? 0.45 : 1; + + public void Refresh(ActionGraph graph) { + Raise(nameof(IsDisabled)); + Raise(nameof(CardOpacity)); + Raise(nameof(IgnoresErrors)); + Raise(nameof(IsMultiInput)); + Input.RefreshMode(); + IsStart = !IsTrigger && !graph.Incoming(Node.Id).Any(); + + List outgoing = graph.Edges.Where(e => e.From == Node.Id).ToList(); + Branches = outgoing.GroupBy(e => e.Port).Select(g => g.Count()).DefaultIfEmpty(0).Max(); + IsEnd = outgoing.Count == 0; + Error = Node.Step.IsValid(out string error) ? null : error; + + Raise(nameof(Title)); + Raise(nameof(Summary)); + Raise(nameof(IsStart)); + Raise(nameof(IsEnd)); + Raise(nameof(Branches)); + Raise(nameof(HasBranches)); + Raise(nameof(BranchLabel)); + Raise(nameof(Error)); + Raise(nameof(HasError)); + } +} + +public sealed class ActionConnectorVm(ActionNodeVm owner, bool isInput, string? port = null) : ActionEditorVm { + private Point _anchor; + + public ActionNodeVm Owner { get; } = owner; + public bool IsInput { get; } = isInput; + public string? Port { get; } = port; + + public bool IsMultiInput => IsInput && Owner.IsMultiInput; + + public string Tip => IsMultiInput + ? "Multiple: runs each time any connected step finishes" + : "Wait: runs when all connected steps have finished"; + + public void RefreshMode() { + Raise(nameof(IsMultiInput)); + Raise(nameof(Tip)); + } + + public Point Anchor { + get => _anchor; + set { + if (_anchor == value) return; + _anchor = value; + Raise(); + } + } +} + +public sealed record ActionConnectionVm(ActionEdge Edge, ActionConnectorVm Source, ActionConnectorVm Target) { + public bool IsElse => Edge.Port == ActionEdge.ElsePort; +} + +internal sealed class EditorCommand(Action execute) : ICommand { + public event EventHandler? CanExecuteChanged { + add { } + remove { } + } + + public bool CanExecute(object? parameter) { + return true; + } + + public void Execute(object? parameter) { + execute(parameter); + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/Actions/ActionEditorWindow.Graph.cs b/SubathonManager.UI/Views/Actions/ActionEditorWindow.Graph.cs new file mode 100644 index 00000000..12558eaa --- /dev/null +++ b/SubathonManager.UI/Views/Actions/ActionEditorWindow.Graph.cs @@ -0,0 +1,405 @@ +using System.Text.Json; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Nodify.Avalonia; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Objects; + +namespace SubathonManager.UI.Views.Actions; + +public partial class ActionEditorWindow { + private const double RightClickSlop = 6; + private Point? _rightPressAt; + + private string PaletteQuery => (PaletteSearchBox.Text ?? "").Trim(); + + private sealed record PaletteItem(ActionStepType Type, SubathonTrigger? Trigger, string Label); + + private static IEnumerable PaletteItems(ActionStepType type) { + if (type != ActionStepType.Trigger) return [new PaletteItem(type, null, type.GetLabel())]; + return Enum.GetValues().OrderBy(t => t.GetOrderNumber()) + .Select(t => new PaletteItem(type, t, t.GetLabel())); + } + + private void BuildPalette() { + foreach (IGrouping group in Enum.GetValues() + .Where(t => t.IsAvailable()).GroupBy(t => t.GetGroup()) + .OrderBy(g => g.Contains(ActionStepType.Trigger) ? 0 : 1)) { + var buttons = new StackPanel { Spacing = 4 }; + var section = new Expander { + Header = group.Key, IsExpanded = true, HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch, Padding = new Thickness(6, 4), + Content = buttons + }; + section.Expanded += (_, _) => { + if (PaletteQuery.Length == 0) _collapsedGroups.Remove(group.Key); + }; + section.Collapsed += (_, _) => { + if (PaletteQuery.Length == 0) _collapsedGroups.Add(group.Key); + }; + + foreach (PaletteItem item in group.SelectMany(PaletteItems)) { + var button = new Button { + Content = $"+ {item.Label}", + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Left, + FontSize = 12, + Tag = item + }; + + button.Click += (_, _) => AddStep(item); + buttons.Children.Add(button); + } + + _paletteSections.Add((group.Key, section, buttons)); + PalettePanel.Children.Add(section); + } + + PaletteSearchBox.TextChanged += (_, _) => FilterPalette(); + PaletteSearchBox.KeyDown += (_, e) => { + if (e.Key != Key.Enter || FirstPaletteMatch() is not { } item) return; + AddStep(item); + e.Handled = true; + }; + } + + private static bool PaletteMatches(string group, PaletteItem item, string query) { + return group.Contains(query, StringComparison.OrdinalIgnoreCase) + || item.Label.Contains(query, StringComparison.OrdinalIgnoreCase); + } + + private void FilterPalette() { + string query = PaletteQuery; + foreach ((string group, Expander section, StackPanel buttons) in _paletteSections) { + var any = false; + foreach (Button button in buttons.Children.OfType + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml.cs b/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml.cs new file mode 100644 index 00000000..1d85080d --- /dev/null +++ b/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml.cs @@ -0,0 +1,325 @@ +using System.Collections.ObjectModel; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using FluentAvalonia.UI.Controls; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Objects; +using SubathonManager.Integration; +using SubathonManager.Services; +using SubathonManager.UI.Services; +using SubathonManager.UI.UiUtils; + +namespace SubathonManager.UI.Views.Actions; + +public partial class ActionEditorWindow : Window { + private const double StepSpacingX = 260; + private const double StepSpacingY = 90; + + private static readonly Dictionary OpenEditors = []; + + private readonly CustomAction _action; + private readonly HashSet _collapsedGroups = new(StringComparer.Ordinal); + private readonly ObservableCollection _connections = []; + private readonly ILogger? _logger = AppServices.Provider.GetService>(); + private readonly ObservableCollection _nodes = []; + private readonly List<(string Group, Expander Section, StackPanel Buttons)> _paletteSections = []; + + private readonly Dictionary _targetIds = new(StringComparer.OrdinalIgnoreCase); + private bool _closeConfirmed; + + private bool _dirty; + private IReadOnlyList? _mixItUpCommands; + private string _savedState = ""; + private IReadOnlyList? _streamerBotActions; + private int _suppress; + + public ActionEditorWindow() : this(new CustomAction()) { + } + + public ActionEditorWindow(CustomAction action) { + InitializeComponent(); + WindowIcons.Apply(this); + _action = action.Clone(); + + foreach (ActionNode node in Graph.Nodes) _nodes.Add(new ActionNodeVm(node)); + foreach (ActionEdge edge in Graph.Edges) AddConnectionView(edge); + RefreshNodes(); + + Editor.ItemsSource = _nodes; + Editor.Connections = _connections; + Editor.PendingConnection = new object(); + Editor.ConnectionCompletedCommand = new EditorCommand(OnConnectionCompleted); + Editor.RemoveConnectionCommand = new EditorCommand(p => { + if (p is ActionConnectionVm connection) RemoveConnections([connection]); + }); + Editor.DisconnectConnectorCommand = new EditorCommand(p => { + if (p is ActionConnectorVm connector) + RemoveConnections(_connections.Where(c => c.Source == connector || c.Target == connector).ToList()); + }); + Editor.ItemsDragCompletedCommand = new EditorCommand(_ => MarkDirty()); + Editor.SelectionChanged += (_, _) => ShowSelection(); + + BuildPalette(); + BuildVariablesPanel(); + TriggerEventTypes.SelectionChanged += (_, _) => TriggerField_Changed(null, new RoutedEventArgs()); + AttachNodeMenus(); + LoadHeader(); + ShowSelection(); + Validate(); + + foreach (TextBox box in new[] { NameBox, AuthorBox, VersionBox, DescriptionBox }) + box.TextChanged += (_, _) => { + if (_suppress > 0) return; + MarkDirty(); + if (box == NameBox) Title = $"Custom Action - {NameBox.Text}"; + }; + foreach (TextBox box in new[] + { ValueBox, DurationBox, BodyBox, HeadersBox, AuthUserBox, AuthTokenBox, OutputBox }) + box.TextChanged += (_, _) => ApplyStepFields(); + + foreach (AutoCompleteBox box in new[] { ScopeBox, TargetBox }) + box.TextChanged += (_, _) => ApplyStepFields(); + + ScopeBox.LostFocus += (_, _) => _ = LoadTargetSuggestionsAsync(); + ScopeBox.SelectionChanged += (_, _) => Dispatcher.UIThread.Post(() => _ = LoadTargetSuggestionsAsync()); + + AddHandler(KeyDownEvent, Window_KeyDown, RoutingStrategies.Tunnel); + ResetDirty(); + Opened += (_, _) => { + if (_nodes.Count > 0) Editor.FitToScreen(); + Dispatcher.UIThread.Post(ResetDirty, DispatcherPriority.Background); + }; + Closing += Window_Closing; + } + + private ActionGraph Graph => _action.Graph; + + private ActionNodeVm? SelectedVm => + Editor.SelectedItems is { Count: 1 } ? Editor.SelectedItems[0] as ActionNodeVm : null; + + private ActionNode? SelectedNode => SelectedVm?.Node; + + public event Action? Saved; + + public static ActionEditorWindow Open(CustomAction action) { + if (OpenEditors.TryGetValue(action.Id, out ActionEditorWindow? existing)) { + if (existing.WindowState == WindowState.Minimized) existing.WindowState = WindowState.Normal; + existing.Activate(); + return existing; + } + + var editor = new ActionEditorWindow(action); + OpenEditors[action.Id] = editor; + editor.Closed += (_, _) => OpenEditors.Remove(action.Id); + editor.Show(); + return editor; + } + + private void LoadHeader() { + _suppress++; + try { + Title = $"Custom Action - {_action.Name}"; + NameBox.Text = _action.Name; + AuthorBox.Text = _action.Author ?? ""; + VersionBox.Text = _action.Version; + DescriptionBox.Text = _action.Description ?? ""; + + RefreshRepeatOptions(); + } + finally { + _suppress--; + } + } + + private void ApplyHeader() { + _action.Name = (NameBox.Text ?? "").Trim(); + _action.Author = string.IsNullOrWhiteSpace(AuthorBox.Text) ? null : AuthorBox.Text.Trim(); + _action.Version = string.IsNullOrWhiteSpace(VersionBox.Text) ? "1.0.0" : VersionBox.Text.Trim(); + _action.Description = string.IsNullOrWhiteSpace(DescriptionBox.Text) ? null : DescriptionBox.Text.Trim(); + } + + private void RefreshRepeatOptions() { + bool triggers = Graph.HasTriggers; + List<(ActionRepeatMode Mode, string Label)> options = new[] { + (ActionRepeatMode.Restart, "Restart Existing"), + (ActionRepeatMode.Parallel, "Run Parallel"), + (ActionRepeatMode.Queue, "Queue New"), + (ActionRepeatMode.Skip, "Skip New") + }.Where(o => !triggers || ActionGraph.AllowedWithTriggers(o.Item1)).ToList(); + + bool loaded = RepeatBox.Items.Count > 0; + if (triggers && !ActionGraph.AllowedWithTriggers(Graph.OnRepeat)) { + Graph.OnRepeat = ActionRepeatMode.Parallel; + StatusText.Foreground = Brushes.Gray; + StatusText.Text = "Actions with triggers can only run in parallel or queued - this action now runs in parallel"; + if (loaded && _suppress == 0) MarkDirty(); + } + + _suppress++; + try { + if (!RepeatBox.Items.OfType().Select(i => (ActionRepeatMode)i.Tag!) + .SequenceEqual(options.Select(o => o.Mode))) { + RepeatBox.Items.Clear(); + foreach ((ActionRepeatMode mode, string label) in options) + RepeatBox.Items.Add(new ComboBoxItem { Content = label, Tag = mode }); + } + + RepeatBox.SelectedItem = RepeatBox.Items.OfType() + .FirstOrDefault(i => (ActionRepeatMode?)i.Tag == Graph.OnRepeat); + ToolTip.SetTip(RepeatBox, triggers + ? "Triggers can fire many times in a row, so they run either in parallel or in a queue" + : null); + } + finally { + _suppress--; + } + } + + private void Repeat_SelectionChanged(object? sender, SelectionChangedEventArgs e) { + if (_suppress > 0 || (RepeatBox.SelectedItem as ComboBoxItem)?.Tag is not ActionRepeatMode mode) return; + Graph.OnRepeat = mode; + MarkDirty(); + } + + private void MarkDirty() { + _dirty = CurrentState() != _savedState; + UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, _dirty); + } + + private void ResetDirty() { + _savedState = CurrentState(); + _dirty = false; + UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, false); + } + + private string CurrentState() { + ApplyHeader(); + return _action.ToJson(); + } + + private void Validate() { + if (Graph.IsValid(out string error)) { + StatusText.Foreground = Brushes.Gray; + StatusText.Text = Graph.Nodes.Count == 0 + ? "No actions yet - add one from the left" + : $"{Graph.Nodes.Count} action(s), {Graph.Edges.Count} connection(s) - ready"; + } + else { + StatusText.Foreground = Brushes.OrangeRed; + StatusText.Text = error; + } + } + + private async void Save_Click(object? sender, RoutedEventArgs e) { + await SaveAsync(); + } + + private async Task SaveAsync() { + ApplyHeader(); + if (string.IsNullOrWhiteSpace(_action.Name)) { + StatusText.Foreground = Brushes.OrangeRed; + StatusText.Text = "Give the action a name before saving"; + return false; + } + + try { + if (ServiceManager.Actions.GetCustomAction(_action.Id) is { } current) _action.Disabled = current.Disabled; + await ServiceManager.Actions.SaveCustomActionAsync(_action.Clone()); + } + catch (Exception ex) { + _logger?.LogError(ex, "[ActionEditor] Saving {Name} failed", _action.Name); + StatusText.Foreground = Brushes.OrangeRed; + StatusText.Text = $"Could not save: {ex.Message}"; + return false; + } + + ResetDirty(); + Validate(); + StatusText.Text = Graph.IsValid(out string error) + ? "Saved" + : $"Save - Will not run until errors are fixed: {error}"; + Saved?.Invoke(_action.Id); + + return true; + } + + private async void TestRun_Click(object? sender, RoutedEventArgs e) { + ApplyHeader(); + if (!Graph.IsValid(out string error)) { + StatusText.Foreground = Brushes.OrangeRed; + StatusText.Text = error; + return; + } + + if (Graph.Nodes.All(n => n.Step.Type == ActionStepType.Trigger || Graph.Incoming(n.Id).Any())) { + StatusText.Foreground = Brushes.OrangeRed; + StatusText.Text = "Every branch starts with a trigger - right click a trigger to run a Test"; + return; + } + + StatusText.Foreground = Brushes.Gray; + StatusText.Text = "Test run started..."; + ShowTestResult(await ServiceManager.Actions.RunManuallyAsync(_action.Clone(), true)); + } + + private async Task TestTriggerAsync(string nodeId) { + ApplyHeader(); + if (!Graph.IsValid(out string error)) { + StatusText.Foreground = Brushes.OrangeRed; + StatusText.Text = error; + return; + } + + StatusText.Foreground = Brushes.Gray; + StatusText.Text = "Trigger test started..."; + ShowTestResult(await ServiceManager.Actions.TestTriggerAsync(_action.Clone(), nodeId, true)); + } + + private void ShowTestResult(ActionRunResult result) { + StatusText.Text = result switch { + ActionRunResult.Done => "Test run finished", + ActionRunResult.Skipped => "Test run skipped: one is already running and this action ignores repeats", + ActionRunResult.Cancelled => "Test run was cancelled", + _ => "Test run stopped at a step that could not run - check the logs" + }; + } + + private async void Export_Click(object? sender, RoutedEventArgs e) { + ApplyHeader(); + try { + string path = await ActionService.ExportCustomActionAsync(_action.Clone()); + StatusText.Foreground = Brushes.Gray; + StatusText.Text = $"Exported to {path}"; + UiHelpers.OpenFolder(Path.GetDirectoryName(path)); + } + catch (Exception ex) { + _logger?.LogError(ex, "[ActionEditor] Export failed"); + StatusText.Foreground = Brushes.OrangeRed; + StatusText.Text = $"Export failed: {ex.Message}"; + } + } + + private async void Window_Closing(object? sender, WindowClosingEventArgs e) { + if (!_dirty || _closeConfirmed) return; + e.Cancel = true; + + var dialog = new FAContentDialog { + Title = "Unsaved changes", + Content = $"Save the changes to \"{(NameBox.Text ?? "").Trim()}\" before closing?", + PrimaryButtonText = "Save", + SecondaryButtonText = "Discard", + CloseButtonText = "Cancel" + }; + FAContentDialogResult choice = await dialog.ShowAsync(this); + if (choice == FAContentDialogResult.None) return; + if (choice == FAContentDialogResult.Primary && !await SaveAsync()) return; + + _closeConfirmed = true; + Close(); + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/Actions/ActionsView.Globals.cs b/SubathonManager.UI/Views/Actions/ActionsView.Globals.cs new file mode 100644 index 00000000..1e8933fd --- /dev/null +++ b/SubathonManager.UI/Views/Actions/ActionsView.Globals.cs @@ -0,0 +1,400 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using FluentAvalonia.UI.Controls; +using Microsoft.Extensions.Logging; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; +using SubathonManager.UI.Controls; +using SubathonManager.UI.Services; +using SubathonManager.UI.UiUtils; + +namespace SubathonManager.UI.Views.Actions; + +public partial class ActionsView { + private static readonly FontFamily Mono = new("Consolas, Menlo, monospace"); + + private readonly List _globalRows = []; + private readonly List _secretRows = []; + private int _storeSuppress; + + private void InitStore() { + EnterKeyCommit.Attach(GlobalsPanel, source => _ = CommitFromAsync(source, _globalRows)); + EnterKeyCommit.Attach(SecretsPanel, source => _ = CommitFromAsync(source, _secretRows)); + } + + private void OnGlobalsChanged() { + Dispatcher.UIThread.Post(() => _ = RefreshStoreAsync()); + } + + private List RowsOf(ActionStoreKind kind) { + return kind == ActionStoreKind.Global ? _globalRows : _secretRows; + } + + private StackPanel PanelOf(ActionStoreKind kind) { + return kind == ActionStoreKind.Global ? GlobalsPanel : SecretsPanel; + } + + private async Task RefreshStoreAsync() { + List all; + try { + all = await ServiceManager.Actions.GetGlobalsAsync(); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[Actions] Could not load globals and secrets"); + StoreStatusText.Text = "Could not load globals and secrets - check the logs"; + return; + } + + foreach (ActionStoreKind kind in Enum.GetValues()) + Reconcile(kind, all.Where(g => g.Kind == kind).ToList()); + RefreshSavePending(); + } + + private void Reconcile(ActionStoreKind kind, List entries) { + List rows = RowsOf(kind); + + foreach (StoreRow gone in rows.Where(r => !r.IsNew && !entries.Any(e => SameName(e.Name, r.Name))).ToList()) + RemoveRow(gone); + + foreach (ActionGlobal entry in entries) { + StoreRow? row = rows.FirstOrDefault(r => !r.IsNew && SameName(r.Name, entry.Name)); + if (row == null) { + row = BuildRow(kind); + row.Name = entry.Name; + row.NameBox.Text = entry.Name; + row.NameBox.IsReadOnly = true; + rows.Add(row); + PanelOf(kind).Children.Add(row.Root); + } + + UpdateRow(row, entry); + } + + if (rows.Count == 0) AddRow(kind, false, false); + } + + private static bool SameName(string? a, string? b) { + return string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + } + + private void UpdateRow(StoreRow row, ActionGlobal entry) { + string value = row.Kind == ActionStoreKind.Secret + ? ServiceManager.Actions.GetSecretValue(entry.Name) ?? "" + : entry.Value ?? ""; + + _storeSuppress++; + try { + bool valueEdited = (row.ValueBox.Text ?? "") != row.Saved; + bool typeEdited = row.TypeBox != null && SelectedType(row) != row.SavedType; + if (!valueEdited) row.ValueBox.Text = value; + if (row.TypeBox != null && !typeEdited) SelectType(row, entry.ValueType); + row.Saved = value; + row.SavedType = entry.ValueType; + } + finally { + _storeSuppress--; + } + + RefreshRowState(row); + } + + private void RefreshRowState(StoreRow row) { + string name = row.IsNew ? (row.NameBox.Text ?? "").Trim() : row.Name!; + row.CopyButton.IsEnabled = ActionStepTypeHelper.IsValidName(name); + if (row.IsNew) { + row.Detail.IsVisible = false; + return; + } + + List usedBy = ServiceManager.Actions.ActionsUsing(row.Kind, row.Name!).Select(a => a.Name).ToList(); + string uses = usedBy.Count == 0 ? "Not used by any action" : $"Used by {string.Join(", ", usedBy)}"; + bool empty = row.Saved.Length == 0; + row.Detail.IsVisible = true; + row.Detail.Foreground = empty ? Brushes.Orange : Brushes.Gray; + row.Detail.Text = empty ? $"No value - steps using it get it empty. {uses}" : uses; + } + + private void RefreshSavePending() { + UiHelpers.UpdateButtonPendingBorder(StoreSaveBorder, _globalRows.Concat(_secretRows).Any(r => r.Dirty)); + } + + private StoreRow BuildRow(ActionStoreKind kind) { + var nameBox = new TextBox { + Height = 34, FontFamily = Mono, + PlaceholderText = kind == ActionStoreKind.Global ? "name, e.g. deaths" : "name, e.g. my_token" + }; + var valueBox = new TextBox { Height = 34, PlaceholderText = "value" }; + if (kind == ActionStoreKind.Secret) { + valueBox.PasswordChar = '*'; + TextBoxAssist.SetReveal(valueBox, true); + } + + var copy = new Button { + Classes = { "opaquesecondary", "iconbtn" }, Width = 34, Height = 34, Padding = new Thickness(2), + Content = new SymIcon { Glyph = "Clipboard20" } + }; + var delete = new Button { + Classes = { "danger", "iconbtn" }, Width = 34, Height = 34, Padding = new Thickness(2), + Content = new SymIcon { Glyph = "Delete20" } + }; + ToolTip.SetTip(delete, "Delete"); + var detail = new TextBlock { + FontSize = 11, Foreground = Brushes.Gray, TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(4, 4, 0, 0), + IsVisible = false + }; + + ComboBox? typeBox = null; + if (kind == ActionStoreKind.Global) { + typeBox = new ComboBox { Height = 34, HorizontalAlignment = HorizontalAlignment.Stretch }; + foreach (ActionValueType type in Enum.GetValues()) + typeBox.Items.Add(new ComboBoxItem { Content = type.GetLabel(), Tag = type }); + typeBox.SelectedIndex = 0; + ToolTip.SetTip(typeBox, "What the global holds. Set Global steps must give a value of this type"); + } + + var grid = new Grid { + ColumnDefinitions = + new ColumnDefinitions(kind == ActionStoreKind.Global ? "Auto,2*,110,3*,Auto" : "Auto,2*,0,3*,Auto"), + ColumnSpacing = 6 + }; + + grid.Children.Add(copy); + Grid.SetColumn(nameBox, 1); + grid.Children.Add(nameBox); + if (typeBox != null) { + Grid.SetColumn(typeBox, 2); + grid.Children.Add(typeBox); + } + + Grid.SetColumn(valueBox, 3); + grid.Children.Add(valueBox); + Grid.SetColumn(delete, 4); + grid.Children.Add(delete); + + var card = new Border { + Padding = new Thickness(10, 8), CornerRadius = new CornerRadius(6), BorderThickness = new Thickness(1), + Child = new StackPanel { Children = { grid, detail } } + }; + card.SetDynamicResource(Border.BorderBrushProperty, "UiBrushBorder"); + card.SetDynamicResource(Border.BackgroundProperty, "UiBrushControlBackground"); + + var row = new StoreRow { + Kind = kind, Root = card, NameBox = nameBox, ValueBox = valueBox, TypeBox = typeBox, CopyButton = copy, + Detail = detail + }; + + nameBox.TextChanged += (_, _) => Edited(row); + valueBox.TextChanged += (_, _) => Edited(row); + if (typeBox != null) + typeBox.SelectionChanged += (_, _) => { + UpdateValueHint(row); + Edited(row); + }; + copy.Click += async (_, _) => { + string name = row.IsNew ? (row.NameBox.Text ?? "").Trim() : row.Name!; + string placeholder = ActionStepTypeHelper.StorePlaceholder(kind, name); + await UiHelpers.TrySetClipboardTextAsync(placeholder); + StoreStatusText.Text = $"Copied {placeholder}"; + }; + delete.Click += async (_, _) => await DeleteRowAsync(row); + + UpdateValueHint(row); + RefreshCopyTip(row); + return row; + } + + private void Edited(StoreRow row) { + if (_storeSuppress > 0) return; + RefreshRowState(row); + RefreshCopyTip(row); + RefreshSavePending(); + } + + private static void RefreshCopyTip(StoreRow row) { + string name = row.IsNew ? (row.NameBox.Text ?? "").Trim() : row.Name!; + ToolTip.SetTip(row.CopyButton, ActionStepTypeHelper.IsValidName(name) + ? $"Copy {ActionStepTypeHelper.StorePlaceholder(row.Kind, name)}" + : "Names are letters, numbers and _ only (max length: 64)"); + } + + private static ActionValueType SelectedType(StoreRow row) { + return (row.TypeBox?.SelectedItem as ComboBoxItem)?.Tag is ActionValueType type ? type : ActionValueType.Text; + } + + private static void SelectType(StoreRow row, ActionValueType type) { + row.TypeBox!.SelectedItem = row.TypeBox.Items.OfType() + .FirstOrDefault(i => (ActionValueType?)i.Tag == type); + UpdateValueHint(row); + } + + private static void UpdateValueHint(StoreRow row) { + if (row.Kind == ActionStoreKind.Secret) return; + row.ValueBox.PlaceholderText = SelectedType(row) switch { + ActionValueType.Number => "a number", + ActionValueType.Boolean => "true or false", + _ => "text" + }; + } + + private StoreRow AddRow(ActionStoreKind kind, bool focus, bool atTop) { + StoreRow row = BuildRow(kind); + List rows = RowsOf(kind); + int at = atTop ? 0 : rows.Count; + rows.Insert(at, row); + PanelOf(kind).Children.Insert(at, row.Root); + RefreshRowState(row); + if (focus) + Dispatcher.UIThread.Post(() => { + row.Root.BringIntoView(); + row.NameBox.Focus(); + }, DispatcherPriority.Background); + return row; + } + + private void RemoveRow(StoreRow row) { + RowsOf(row.Kind).Remove(row); + PanelOf(row.Kind).Children.Remove(row.Root); + } + + private void AddGlobalRow_Click(object? sender, RoutedEventArgs e) { + AddRow(ActionStoreKind.Global, true, true); + } + + private void AddSecretRow_Click(object? sender, RoutedEventArgs e) { + AddRow(ActionStoreKind.Secret, true, true); + } + + private async Task CommitFromAsync(object? source, List rows) { + StoreRow? row = rows.FirstOrDefault(r => source is Visual v && (v == r.Root || r.Root.IsVisualAncestorOf(v))); + if (row == null) return; + if (row.IsNew && string.IsNullOrWhiteSpace(row.NameBox.Text)) return; + if (row.Dirty && await SaveRowAsync(row) is { } error) { + StoreStatusText.Text = error; + return; + } + + if (rows.LastOrDefault() == row) AddRow(row.Kind, true, false); + } + + private async void SaveStore_Click(object? sender, RoutedEventArgs e) { + List changed = _globalRows.Concat(_secretRows) + .Where(r => r.Dirty && !(r.IsNew && string.IsNullOrWhiteSpace(r.NameBox.Text))).ToList(); + if (changed.Count == 0) { + StoreStatusText.Text = "Nothing to save"; + return; + } + + var errors = new List(); + foreach (StoreRow row in changed) + if (await SaveRowAsync(row) is { } error) + errors.Add(error); + + int saved = changed.Count - errors.Count; + StoreStatusText.Text = errors.Count == 0 + ? $"Saved {saved} change(s)" + : $"Saved {saved}, {errors.Count} not saved: {string.Join("; ", errors)}"; + RefreshSavePending(); + } + + private async Task SaveRowAsync(StoreRow row) { + string name = row.IsNew ? (row.NameBox.Text ?? "").Trim() : row.Name!; + string value = row.ValueBox.Text ?? ""; + string placeholder = ActionStepTypeHelper.StorePlaceholder(row.Kind, name); + + if (!ActionStepTypeHelper.IsValidName(name)) + return $"\"{name}\": names are letters, numbers, and _ only (max length: 64)"; + + if (row.IsNew && RowsOf(row.Kind).Any(r => r != row && !r.IsNew && SameName(r.Name, name))) + return $"{placeholder} already exists"; + + string? note = null; + if (row.Kind == ActionStoreKind.Secret) { + if (!await ServiceManager.Actions.SetSecretAsync(name, value)) + return $"could not save {placeholder} - check the logs"; + } + else { + ActionValueType type = SelectedType(row); + if (!row.IsNew && value == row.Saved && type != row.SavedType) { + if (!await ServiceManager.Actions.SetGlobalTypeAsync(name, type)) + note = $"{placeholder}'s value couldn't convert to {type.GetLabel()} so it was cleared"; + value = (await ServiceManager.Actions.GetGlobalsAsync(ActionStoreKind.Global)) + .FirstOrDefault(g => SameName(g.Name, name))?.Value ?? ""; + } + else { + if (await ServiceManager.Actions.SetGlobalAsync(name, type, value) is { } error) + return $"{placeholder}: {error}"; + value = value.Length == 0 ? type.DefaultValue() : type.NormalizeValue(value) ?? ""; + } + + row.SavedType = type; + } + + _storeSuppress++; + try { + row.Name = name; + row.NameBox.IsReadOnly = true; + row.ValueBox.Text = value; + row.Saved = value; + } + finally { + _storeSuppress--; + } + + RefreshRowState(row); + RefreshSavePending(); + StoreStatusText.Text = note ?? $"Saved {placeholder}"; + return null; + } + + private async Task DeleteRowAsync(StoreRow row) { + if (row.IsNew) { + RemoveRow(row); + if (RowsOf(row.Kind).Count == 0) AddRow(row.Kind, false, false); + RefreshSavePending(); + return; + } + + string placeholder = ActionStepTypeHelper.StorePlaceholder(row.Kind, row.Name!); + List usedBy = ServiceManager.Actions.ActionsUsing(row.Kind, row.Name!).Select(a => a.Name).ToList(); + var dialog = new FAContentDialog { + Title = $"Delete {row.Kind.TokenPrefix()}", + Content = usedBy.Count == 0 + ? $"Delete {placeholder}?" + : $"Delete {placeholder}? {usedBy.Count} action(s) use it: {string.Join(", ", usedBy)}. " + + "They'll be replaced with an empty value", + PrimaryButtonText = "Delete", + CloseButtonText = "Cancel" + }; + if (await dialog.ShowAsync() != FAContentDialogResult.Primary) return; + + await ServiceManager.Actions.DeleteGlobalAsync(row.Kind, row.Name!); + StoreStatusText.Text = $"Deleted {placeholder}"; + } + + private sealed class StoreRow { + public required ActionStoreKind Kind { get; init; } + public required Control Root { get; init; } + public required TextBox NameBox { get; init; } + public required TextBox ValueBox { get; init; } + public required ComboBox? TypeBox { get; init; } + public required Button CopyButton { get; init; } + public required TextBlock Detail { get; init; } + + // null until the row is saved + public string? Name { get; set; } + public string Saved { get; set; } = ""; + public ActionValueType SavedType { get; set; } + + public bool IsNew => Name == null; + + public bool Dirty => IsNew + ? !string.IsNullOrWhiteSpace(NameBox.Text) + : (ValueBox.Text ?? "") != Saved || (TypeBox != null && SelectedType(this) != SavedType); + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/Actions/ActionsView.Triggers.cs b/SubathonManager.UI/Views/Actions/ActionsView.Triggers.cs new file mode 100644 index 00000000..7abe8bcd --- /dev/null +++ b/SubathonManager.UI/Views/Actions/ActionsView.Triggers.cs @@ -0,0 +1,152 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Microsoft.Extensions.Logging; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Objects; +using SubathonManager.UI.Controls; +using SubathonManager.UI.Services; +using SubathonManager.UI.UiUtils; + +namespace SubathonManager.UI.Views.Actions; + +public partial class ActionsView { + private static readonly IBrush TriggerAccent = new SolidColorBrush(Color.FromRgb(0x1C, 0xB8, 0x96)); + + private void RefreshTriggers() { + List<(CustomAction Action, ActionNode Node)> steps = ServiceManager.Actions.TriggerSteps().ToList(); + TriggersPanel.Children.Clear(); + TriggersStatusText.Text = steps.Count == 0 ? "No actions have triggers yet" : ""; + + foreach (SubathonTrigger trigger in Enum.GetValues().OrderBy(t => t.GetOrderNumber())) { + List<(CustomAction Action, ActionNode Node)> matching = + steps.Where(s => s.Node.Step.Trigger == trigger).ToList(); + + var section = new StackPanel { Spacing = 6 }; + var header = new StackPanel { + Orientation = Orientation.Horizontal, Spacing = 8, + Children = { + + new Border { + MinWidth = 22, CornerRadius = new CornerRadius(3), Padding = new Thickness(5, 1), + Background = matching.Count > 0 ? TriggerAccent : Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock { + Text = $"{matching.Count}", FontSize = 11, FontWeight = FontWeight.Bold, + Foreground = Brushes.White, HorizontalAlignment = HorizontalAlignment.Center + } + }, + + new TextBlock { + Text = trigger.GetLabel(), FontSize = 15, FontWeight = FontWeight.SemiBold, + VerticalAlignment = VerticalAlignment.Center + }, + + new TextBlock { + Text = trigger.GetDescription(), FontSize = 12, Foreground = Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis + } + } + }; + + SubathonTrigger captured = trigger; + var expander = new Expander { + Header = header, Content = section, IsExpanded = _openTriggers.Contains(trigger), + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch + }; + + expander.Expanded += (_, _) => _openTriggers.Add(captured); + expander.Collapsed += (_, _) => _openTriggers.Remove(captured); + + if (matching.Count == 0) + section.Children.Add(new TextBlock { + Text = "No actions", FontSize = 12, Foreground = Brushes.Gray + }); + + foreach ((CustomAction action, ActionNode node) in matching.OrderBy(m => m.Action.Name, + StringComparer.OrdinalIgnoreCase)) + section.Children.Add(BuildTriggerRow(action, node)); + + TriggersPanel.Children.Add(expander); + } + } + + private readonly HashSet _openTriggers = []; + + private Control BuildTriggerRow(CustomAction action, ActionNode node) { + bool valid = node.Step.IsValid(out string error) && action.Graph.IsValid(out error); + var name = new TextBlock { Text = action.Name, FontWeight = FontWeight.SemiBold, FontSize = 14 }; + var summary = new TextBlock { + Text = node.Step.Describe(), FontSize = 12, Foreground = Brushes.Gray, TextWrapping = TextWrapping.Wrap + }; + var problem = new TextBlock { + Text = valid ? "" : $"Won't run: {error}", FontSize = 12, Foreground = Brushes.OrangeRed, + TextWrapping = TextWrapping.Wrap, IsVisible = !valid + }; + if (valid && action.Disabled) { + problem.Text = "Action is disabled"; + problem.Foreground = Brushes.Gray; + problem.IsVisible = true; + } + + var enabled = new ToggleSwitch { + IsChecked = !node.Disabled, OnContent = "On", OffContent = "Off", VerticalAlignment = VerticalAlignment.Center + }; + + enabled.IsCheckedChanged += async (_, _) => { + CustomAction copy = action.Clone(); + if (copy.Graph.Nodes.FirstOrDefault(n => n.Id == node.Id) is not { } target) return; + target.Disabled = enabled.IsChecked != true; + try { + await ServiceManager.Actions.SaveCustomActionAsync(copy); + TriggersStatusText.Text = $"{(target.Disabled ? "Turned off" : "Turned on")} a trigger in \"{action.Name}\""; + } + catch (Exception ex) { + _logger?.LogError(ex, "[Actions] Saving {Name} failed", action.Name); + TriggersStatusText.Text = $"Could not save \"{action.Name}\": {ex.Message}"; + } + }; + + var test = new Button { + Classes = { "opaquesecondary", "iconbtn" }, Width = 32, Height = 32, Padding = new Thickness(2), + Content = new SymIcon { Glyph = "Play20" }, IsEnabled = valid + }; + + test.Click += async (_, _) => { + TriggersStatusText.Text = $"Testing \"{action.Name}\"..."; + ActionRunResult result = await ServiceManager.Actions.TestTriggerAsync(action, node.Id); + TriggersStatusText.Text = result switch { + ActionRunResult.Done => $"\"{action.Name}\" finished", + ActionRunResult.Skipped => $"\"{action.Name}\" is already running - skipped", + ActionRunResult.Cancelled => $"\"{action.Name}\" was cancelled", + _ => $"\"{action.Name}\" stopped at a step that could not be run - check the logs" + }; + }; + + var open = new Button { + Classes = { "opaquesecondary", "iconbtn" }, Width = 32, Height = 32, Padding = new Thickness(2), + Content = new SymIcon { Glyph = "Edit20" } + }; + ToolTip.SetTip(open, "Open in the editor"); + open.Click += (_, _) => OpenEditor(action); + + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto,Auto"), ColumnSpacing = 8 }; + grid.Children.Add(new StackPanel { Children = { name, summary, problem } }); + Grid.SetColumn(enabled, 1); + grid.Children.Add(enabled); + Grid.SetColumn(test, 2); + grid.Children.Add(test); + Grid.SetColumn(open, 3); + grid.Children.Add(open); + + var card = new Border { + Padding = new Thickness(12, 8), CornerRadius = new CornerRadius(6), BorderThickness = new Thickness(1), + Child = grid, Opacity = node.Disabled || action.Disabled ? 0.6 : 1 + }; + card.SetDynamicResource(Border.BorderBrushProperty, "UiBrushBorder"); + card.SetDynamicResource(Border.BackgroundProperty, "UiBrushControlBackground"); + return card; + } +} diff --git a/SubathonManager.UI/Views/Actions/ActionsView.axaml b/SubathonManager.UI/Views/Actions/ActionsView.axaml new file mode 100644 index 00000000..7bafe1f7 --- /dev/null +++ b/SubathonManager.UI/Views/Actions/ActionsView.axaml @@ -0,0 +1,306 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + DoneBox_Changed(goal, doneBox.IsChecked == true); + var deleteBtn = new Button { Content = new SymIcon { Glyph = "Delete20", HorizontalAlignment = HorizontalAlignment.Center }, Width = 32, Height = 32, @@ -291,13 +302,15 @@ private Grid AddGoalRow(SubathonGoal goal, bool isUnsaved) { Grid.SetColumn(textBox, 1); Grid.SetColumn(pointsBox, 2); - Grid.SetColumn(deleteBtn, 3); + Grid.SetColumn(doneBox, 3); + Grid.SetColumn(deleteBtn, 4); Grid.SetColumn(grip, 0); panel.Children.Add(textBox); panel.Children.Add(pointsBox); panel.Children.Add(deleteBtn); panel.Children.Add(grip); + panel.Children.Add(doneBox); panel.Tag = goal; if (isUnsaved) _unsavedGoals.Add(goal); @@ -313,6 +326,21 @@ private Grid AddGoalRow(SubathonGoal goal, bool isUnsaved) { return row.Children[1] as TextBox; } + private static CheckBox? RowDoneBox(Grid row) { + return row.Children[4] as CheckBox; + } + + private async void DoneBox_Changed(SubathonGoal goal, bool done) { + if (_suppressCount > 0 || goal.IsCompleted == done) return; + goal.IsCompleted = done; + if (_unsavedGoals.Contains(goal)) return; + + await using AppDbContext db = await _factory.CreateDbContextAsync(); + await db.SubathonGoals + .Where(g => g.Id == goal.Id) + .ExecuteUpdateAsync(s => s.SetProperty(g => g.IsCompleted, done)); + } + private List GoalRows() { return GoalsStack.Children.OfType().ToList(); } @@ -510,6 +538,7 @@ private async Task SaveGoalsAsync(object? sender, RoutedEventArgs? e) { goal.Text = textBox.Text ?? ""; goal.Points = pts; + goal.IsCompleted = RowDoneBox(panel)?.IsChecked == true; if (_unsavedGoals.Contains(goal)) db.SubathonGoals.Add(goal); else db.Update(goal); @@ -619,28 +648,13 @@ private async void ExportGoalSet_Click(object? sender, RoutedEventArgs e) { .FirstOrDefaultAsync(s => s.Id == _activeGoalSet.Id); if (set == null) return; - string exportDir = Path.Combine(Config.DataFolder, "exports"); - Directory.CreateDirectory(exportDir); - - string safeName = SafeFileName.Sanitize(set.Name, string.Empty, "goals"); - var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); - string filepath = Path.Combine(exportDir, $"{safeName}-{timestamp}.csv"); - string typeHeader = (set.Type ?? GoalsType.Points) == GoalsType.Money ? "Money" : "Points"; - var sb = new StringBuilder(); - sb.AppendLine($"Goal,Value,{typeHeader}"); - foreach (SubathonGoal goal in set.Goals.OrderBy(g => g.Points)) - sb.AppendLine($"{Utils.EscapeCsv(goal.Text)},{goal.Points}"); - - await File.WriteAllTextAsync(filepath, sb.ToString(), Encoding.UTF8); - - try { - UiHelpers.OpenFolder(exportDir); - } - catch { - /**/ - } + string path = await CsvUtils.ExportAsync(SafeFileName.Sanitize(set.Name, string.Empty, "goals"), + [["Goal", "Value", typeHeader]], + set.Goals.OrderBy(g => g.Points), + goal => [goal.Text, goal.Points]); + UiHelpers.OpenFolder(Path.GetDirectoryName(path)); } private async void ImportGoalSet_Click(object? sender, RoutedEventArgs e) { @@ -656,21 +670,13 @@ private async void ImportGoalSet_Click(object? sender, RoutedEventArgs e) { IStorageFile file = picked[0]; string filePath = file.Path.LocalPath; - string[] lines; - try { - lines = await File.ReadAllLinesAsync(filePath, Encoding.UTF8); - } - catch { + List? lines = await CsvUtils.ReadFileAsync(filePath); + if (lines == null || lines.Count < 1) { await ShowInvalidGoalCsvPopup(); return; } - if (lines.Length < 1) { - await ShowInvalidGoalCsvPopup(); - return; - } - - string[] headerCols = ParseCsvLine(lines[0]); + string[] headerCols = lines[0]; if (headerCols.Length < 2) { await ShowInvalidGoalCsvPopup(); return; @@ -682,9 +688,7 @@ private async void ImportGoalSet_Click(object? sender, RoutedEventArgs e) { goalType = GoalsType.Money; var goals = new List(); - for (var i = 1; i < lines.Length; i++) { - if (string.IsNullOrWhiteSpace(lines[i])) continue; - string[] cols = ParseCsvLine(lines[i]); + foreach (string[] cols in lines.Skip(1)) { if (cols.Length < 2 || !long.TryParse(cols[1].Trim(), out long pts)) { await ShowInvalidGoalCsvPopup(); return; @@ -713,45 +717,6 @@ private async void ImportGoalSet_Click(object? sender, RoutedEventArgs e) { LoadAllSets(); } - private static string[] ParseCsvLine(string line) { - var result = new List(); - var field = new StringBuilder(); - var inQuotes = false; - - for (var i = 0; i < line.Length; i++) { - char c = line[i]; - if (inQuotes) - switch (c) { - case '"' when i + 1 < line.Length && line[i + 1] == '"': - field.Append('"'); - i++; - break; - case '"': - inQuotes = false; - break; - default: - field.Append(c); - break; - } - else - switch (c) { - case '"': - inQuotes = true; - break; - case ',': - result.Add(field.ToString()); - field.Clear(); - break; - default: - field.Append(c); - break; - } - } - - result.Add(field.ToString()); - return result.ToArray(); - } - private async Task ShowInvalidGoalCsvPopup() { var dialog = new FAContentDialog { Title = "Invalid CSV", diff --git a/SubathonManager.UI/Views/ImportMissedWindow.axaml b/SubathonManager.UI/Views/ImportMissedWindow.axaml new file mode 100644 index 00000000..8e723f51 --- /dev/null +++ b/SubathonManager.UI/Views/ImportMissedWindow.axaml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -115,19 +161,23 @@ - + + - + - - + + + @@ -190,12 +240,38 @@ IsCheckedChanged="PromptInfinite_Changed" /> - + + + + + + + + + + + + + + + + - - - + + diff --git a/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs b/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs index fdc638dc..8300f7c3 100644 --- a/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs +++ b/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs @@ -1,4 +1,3 @@ -using System.Text; using Avalonia; using Avalonia.Controls; using Avalonia.Input; @@ -49,6 +48,7 @@ public PromptsEditor() { if (run.Status == SubathonPromptRunStatus.Completed) LoadPromptRows(); }); + InitHistory(); Loaded += (_, _) => { if (!_initialized) { @@ -83,12 +83,14 @@ private static IEnumerable BuildEventEntries(SubathonEventType e if (e == SubathonEventType.GoAffProOrder) return GoAffProStoreRegistry.All().Where(s => s.Enabled) .Select(s => new EventTypeEntry(e, s.EventName, s.SiteId.ToString())); + if (e == SubathonEventType.JuniperMerchSale) return JuniperStoreRegistry.AllStores().Where(s => s.Enabled) .SelectMany(s => new[] { new EventTypeEntry(e, "Any Sale", s.RowId.ToString(), s.StoreName) } .Concat(s.Products .OrderBy(p => p.ProductName, StringComparer.OrdinalIgnoreCase) .Select(p => new EventTypeEntry(e, p.ProductName, p.ProductId.ToString(), s.StoreName)))); + if (e is SubathonEventType.MakeShipPledge or SubathonEventType.MakeShipSale) { bool isPledge = e == SubathonEventType.MakeShipPledge; string category = isPledge ? "Pledges" : "Campaign Sales"; @@ -241,6 +243,7 @@ private void LoadSetById(Guid setId) { DeleteSetBtn.IsEnabled = totalSets > 1; Dispatcher.UIThread.Post(LoadPromptRows); + _ = LoadHistoryAsync(); } private void SetSelectorBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) { @@ -651,6 +654,7 @@ SubathonEventType.MakeShipPledge or SubathonEventType.MakeShipSale } UpdateValueLabel(prompt.Type, prompt.SubType); + PopulatePromptActionBox(prompt.CustomActionId); }); RefreshRowHighlights(clickedRow); @@ -819,6 +823,7 @@ private void WriteDetailToPrompt(SubathonPrompt prompt) { prompt.Type = SelectedType() ?? prompt.Type; prompt.SubType = SelectedSubType() ?? SubathonPromptSubType.Default; + prompt.CustomActionId = SelectedPromptActionId; if (prompt.Type == SubathonPromptType.Event) { prompt.FilterEventType = _selectedFilterEventType; @@ -850,6 +855,7 @@ private static void CopyPromptToTracked(SubathonPrompt source, SubathonPrompt tr tracked.FilterEventType = source.FilterEventType; tracked.FilterMeta = source.FilterMeta; tracked.FilterSubType = source.FilterSubType; + tracked.CustomActionId = source.CustomActionId; } private void MarkPendingChanges() { @@ -937,38 +943,20 @@ private async void ExportPromptSet_Click(object? sender, RoutedEventArgs e) { .FirstOrDefaultAsync(s => s.Id == _activeSet.Id); if (set == null) return; - string exportDir = Path.Combine(Config.DataFolder, "exports"); - Directory.CreateDirectory(exportDir); - - string safeName = SafeFileName.Sanitize(set.Name, string.Empty, "prompts"); - var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); - string filepath = Path.Combine(exportDir, $"{safeName}-{timestamp}.csv"); - - var sb = new StringBuilder(); - sb.AppendLine( - $"#Interval={(int)set.Interval.TotalMinutes},Offset={(int)set.RandomOffset.TotalMinutes},Cooldown={(int)set.Cooldown.TotalMinutes}"); - sb.AppendLine("Text,Value,Duration,Quantity,Infinite,Enabled,Type,SubType,EventType,FilterMeta"); - foreach (SubathonPrompt p in set.Prompts.OrderBy(p => p.Index)) - sb.AppendLine(string.Join(",", - Utils.EscapeCsv(p.Text), - p.Value, - (int)p.CompletionDuration.TotalMinutes, - p.Quantity, - p.IsInfinite, - p.Enabled, - p.Type, - p.SubType, - p.FilterEventType?.ToString() ?? "", - Utils.EscapeCsv(p.FilterMeta ?? ""))); - - await File.WriteAllTextAsync(filepath, sb.ToString(), Encoding.UTF8); - - try { - UiHelpers.OpenFolder(exportDir); - } - catch { - /**/ - } + string path = await CsvUtils.ExportAsync(SafeFileName.Sanitize(set.Name, string.Empty, "prompts"), + [ + [ + $"#Interval={(int)set.Interval.TotalMinutes}", $"Offset={(int)set.RandomOffset.TotalMinutes}", + $"Cooldown={(int)set.Cooldown.TotalMinutes}" + ], + ["Text", "Value", "Duration", "Quantity", "Infinite", "Enabled", "Type", "SubType", "EventType", "FilterMeta"] + ], + set.Prompts.OrderBy(p => p.Index), + p => [ + p.Text, p.Value, (int)p.CompletionDuration.TotalMinutes, p.Quantity, p.IsInfinite, p.Enabled, + p.Type, p.SubType, p.FilterEventType, p.FilterMeta + ]); + UiHelpers.OpenFolder(Path.GetDirectoryName(path)); } private async void ImportPromptSet_Click(object? sender, RoutedEventArgs e) { @@ -983,16 +971,8 @@ private async void ImportPromptSet_Click(object? sender, RoutedEventArgs e) { if (picked.Count == 0) return; string filePath = picked[0].Path.LocalPath; - string[] lines; - try { - lines = await File.ReadAllLinesAsync(filePath, Encoding.UTF8); - } - catch { - await ShowInvalidPromptCsvPopup(); - return; - } - - if (lines.Length < 1) { + List? lines = await CsvUtils.ReadFileAsync(filePath); + if (lines == null || lines.Count < 1) { await ShowInvalidPromptCsvPopup(); return; } @@ -1000,9 +980,9 @@ private async void ImportPromptSet_Click(object? sender, RoutedEventArgs e) { var headerIndex = 0; int intervalMin = 20, offsetMin = 0, cooldownMin = 20; - if (lines[0].StartsWith('#')) { - foreach (string kv in lines[0][1..].Split(',')) { - string[] parts = kv.Split('='); + if (lines[0][0].StartsWith('#')) { + foreach (string kv in lines[0]) { + string[] parts = kv.TrimStart('#').Split('='); if (parts.Length != 2) continue; if (int.TryParse(parts[1].Trim(), out int v)) { if (parts[0].Trim().Equals("Interval", StringComparison.OrdinalIgnoreCase)) intervalMin = v; @@ -1014,15 +994,13 @@ private async void ImportPromptSet_Click(object? sender, RoutedEventArgs e) { headerIndex = 1; } - if (headerIndex >= lines.Length || ParseCsvLine(lines[headerIndex]).Length < 8) { + if (headerIndex >= lines.Count || lines[headerIndex].Length < 8) { await ShowInvalidPromptCsvPopup(); return; } var prompts = new List(); - for (int i = headerIndex + 1; i < lines.Length; i++) { - if (string.IsNullOrWhiteSpace(lines[i])) continue; - string[] cols = ParseCsvLine(lines[i]); + foreach (string[] cols in lines.Skip(headerIndex + 1)) { if (cols.Length < 8 || !long.TryParse(cols[1].Trim(), out long value) || !int.TryParse(cols[2].Trim(), out int durMin) @@ -1104,45 +1082,6 @@ private async void ImportPromptSet_Click(object? sender, RoutedEventArgs e) { LoadActiveSet(); } - private static string[] ParseCsvLine(string line) { - var result = new List(); - var field = new StringBuilder(); - var inQuotes = false; - - for (var i = 0; i < line.Length; i++) { - char c = line[i]; - if (inQuotes) - switch (c) { - case '"' when i + 1 < line.Length && line[i + 1] == '"': - field.Append('"'); - i++; - break; - case '"': - inQuotes = false; - break; - default: - field.Append(c); - break; - } - else - switch (c) { - case '"': - inQuotes = true; - break; - case ',': - result.Add(field.ToString()); - field.Clear(); - break; - default: - field.Append(c); - break; - } - } - - result.Add(field.ToString()); - return result.ToArray(); - } - private static async Task ShowInvalidPromptCsvPopup() { var dialog = new FAContentDialog { Title = "Invalid CSV", @@ -1191,7 +1130,7 @@ private void OnRunStateChanged(Guid promptId, bool isRunning) { } private void ApplyEditorLock(bool locked) { - PromptDetailBorder.IsEnabled = !locked; + PromptDetailFields.IsEnabled = !locked; RunningLockText.IsVisible = locked; SaveBtn.IsEnabled = !locked; } diff --git a/SubathonManager.UI/Views/Schedule/ScheduleView.Csv.cs b/SubathonManager.UI/Views/Schedule/ScheduleView.Csv.cs index 3f39faff..f308df69 100644 --- a/SubathonManager.UI/Views/Schedule/ScheduleView.Csv.cs +++ b/SubathonManager.UI/Views/Schedule/ScheduleView.Csv.cs @@ -58,7 +58,7 @@ private async void ExportCsv_Click(object? sender, RoutedEventArgs e) { ? $"schedule-{range.Value.from:yyyy-MM-dd}" : $"schedule-{range.Value.from:yyyy-MM-dd}-to-{range.Value.to:yyyy-MM-dd}"; - string exportDir = Path.Combine(Config.DataFolder, "exports"); + string exportDir = CsvUtils.ExportFolder; Directory.CreateDirectory(exportDir); IStorageFolder? startFolder = await top.StorageProvider.TryGetFolderFromPathAsync(exportDir); diff --git a/SubathonManager.UI/Views/SettingsControl.cs b/SubathonManager.UI/Views/SettingsControl.cs index 30cfaa93..c39e0b35 100644 --- a/SubathonManager.UI/Views/SettingsControl.cs +++ b/SubathonManager.UI/Views/SettingsControl.cs @@ -45,6 +45,17 @@ protected SettingsControl() { protected virtual bool allowMembershipDelete => true; + // synced-tier integrations + protected virtual TextBox? _DefaultMembershipSecondsBox => null; + protected virtual TextBox? _DefaultMembershipPointsBox => null; + protected virtual ComboBox? _MembershipTierCombo => null; + + // a non-tier value row + protected virtual string? _PerUnitMembershipMeta => null; + + protected virtual void OnMembershipRowsLoaded() { + } + public virtual void Init(SettingsView host) { Host = host; } @@ -256,6 +267,134 @@ internal void DeleteRow(SubathonValue subathonValue, DynamicSubRow subRow) { _dynamicSubRows.Remove(subRow); _MembershipsPanel.Children.Remove(subRow.RowGrid); } + + internal void SyncMembershipTiers(IEnumerable tierNames) { + if (_membershipEventType is not { } type) return; + List names = tierNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct().ToList(); + using AppDbContext db = _factory.CreateDbContext(); + List existing = db.SubathonValues.Where(v => v.EventType == type && names.Contains(v.Meta)) + .Select(v => v.Meta).ToList(); + // new tiers start from the DEFAULT row so they count the same until set + SubathonValue? fallback = db.SubathonValues.AsNoTracking() + .FirstOrDefault(v => v.EventType == type && v.Meta == "DEFAULT"); + List newValues = names.Where(n => !existing.Contains(n)) + .Select(n => new SubathonValue { + Meta = n, Seconds = fallback?.Seconds ?? 0, Points = fallback?.Points ?? 0, EventType = type + }) + .ToList(); + if (newValues.Count == 0) return; + + db.SubathonValues.AddRange(newValues); + db.SaveChanges(); + Dispatcher.UIThread.Post(() => SuppressUnsavedChanges(() => LoadMembershipValues(null))); + } + + internal void LoadMembershipValues(AppDbContext? db) { + if (_membershipEventType is not { } type || _MembershipsPanel == null) return; + using AppDbContext? owned = db == null ? _factory.CreateDbContext() : null; + db ??= owned!; + List values = db.SubathonValues.Where(v => v.EventType == type) + .OrderBy(v => v.Meta).AsNoTracking().ToList(); + + for (int i = _MembershipsPanel.Children.Count - 1; i >= 0; i--) { + Control child = _MembershipsPanel.Children[i]; + if (child.Name != "DefaultMember" && child.Name != "AddBtn") + _MembershipsPanel.Children.RemoveAt(i); + } + + _dynamicSubRows.Clear(); + foreach (SubathonValue value in values) { + if (value.Meta == _PerUnitMembershipMeta) continue; + if (value.Meta != "DEFAULT") { + AddMembershipRow(value); + continue; + } + + if (_DefaultMembershipSecondsBox != null && _DefaultMembershipPointsBox != null) + Host.UpdateTimePointsBoxes(_DefaultMembershipSecondsBox, _DefaultMembershipPointsBox, + $"{value.Seconds}", $"{value.Points}"); + } + + RefreshMembershipTierCombo(values.Select(v => v.Meta).Where(m => m != _PerUnitMembershipMeta)); + OnMembershipRowsLoaded(); + } + + internal bool SaveMembershipValues(AppDbContext db) { + if (_membershipEventType is not { } type) return false; + var hasUpdated = false; + + SubathonValue? defaultValue = + db.SubathonValues.FirstOrDefault(sv => sv.EventType == type && sv.Meta == "DEFAULT"); + if (defaultValue != null && Utils.TryParseAmount(_DefaultMembershipSecondsBox?.Text, out double defaultSeconds) && + !defaultSeconds.Equals(defaultValue.Seconds)) { + defaultValue.Seconds = defaultSeconds; + hasUpdated = true; + } + + if (defaultValue != null && Utils.TryParseAmount(_DefaultMembershipPointsBox?.Text, out double defaultPoints) && + !defaultPoints.Equals(defaultValue.Points)) { + defaultValue.Points = defaultPoints; + hasUpdated = true; + } + + List removeRows = + _dynamicSubRows.Where(row => string.IsNullOrWhiteSpace(row.NameBox.Text)).ToList(); + if (removeRows.Count > 0) hasUpdated = true; + foreach (DynamicSubRow row in removeRows) + DeleteRow(row.SubValue, row); + + EnsureUniqueName(_dynamicSubRows); + + foreach (DynamicSubRow subRow in _dynamicSubRows) { + string meta = (subRow.NameBox.Text ?? "").Trim(); + if (meta == "DEFAULT") continue; + if (!Utils.TryParseAmount(subRow.TimeBox.Text, out double seconds)) seconds = 0; + if (!Utils.TryParseAmount(subRow.PointsBox.Text, out double points)) points = 0; + +#pragma warning disable CA1862 + SubathonValue? existing = db.SubathonValues.FirstOrDefault(sv => + sv.EventType == type && sv.Meta.ToLower() == meta.ToLower()); +#pragma warning restore CA1862 + if (existing != null) { + hasUpdated |= !seconds.Equals(existing.Seconds) || !points.Equals(existing.Points); + existing.Seconds = seconds; + existing.Points = points; + subRow.SubValue = existing; + } + else { + subRow.SubValue.Meta = meta; + subRow.SubValue.Seconds = seconds; + subRow.SubValue.Points = points; + db.SubathonValues.Add(subRow.SubValue); + hasUpdated = true; + } + } + + List names = ["DEFAULT", .. _dynamicSubRows.Select(row => (row.NameBox.Text ?? "").Trim())]; + if (_PerUnitMembershipMeta != null) names.Add(_PerUnitMembershipMeta); + List stale = db.SubathonValues.Where(x => x.EventType == type && !names.Contains(x.Meta)) + .ToList(); + if (stale.Count > 0) { + db.SubathonValues.RemoveRange(stale); + hasUpdated = true; + } + + return hasUpdated; + } + + private void RefreshMembershipTierCombo(IEnumerable metas) { + if (_MembershipTierCombo is not { } combo) return; + string selectedTier = (combo.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? ""; + + combo.Items.Clear(); + combo.Items.Add(new ComboBoxItem { Content = "DEFAULT" }); + foreach (string meta in metas.Where(m => m != "DEFAULT" && !string.IsNullOrWhiteSpace(m)).Distinct() + .OrderBy(m => m)) + combo.Items.Add(new ComboBoxItem { Content = meta }); + + combo.SelectedItem = combo.Items.OfType().FirstOrDefault(i => + string.Equals(i.Content?.ToString(), selectedTier, StringComparison.OrdinalIgnoreCase)) ?? combo.Items[0]; + } } public class DynamicSubRow { diff --git a/SubathonManager.UI/Views/SettingsView.Settings.cs b/SubathonManager.UI/Views/SettingsView.Settings.cs index 66e14f74..06d4ac5f 100644 --- a/SubathonManager.UI/Views/SettingsView.Settings.cs +++ b/SubathonManager.UI/Views/SettingsView.Settings.cs @@ -68,7 +68,8 @@ private void EventsSummary_Click(object? sender, RoutedEventArgs e) { private async void ExportEvents_Click(object? sender, RoutedEventArgs e) { await using AppDbContext db = await _factory.CreateDbContextAsync(); - await AppDbContext.ActiveEventsToCsv(db); + string? path = await AppDbContext.ActiveEventsToCsv(db); + if (path != null) UiHelpers.OpenFolder(Path.GetDirectoryName(path)); } private void UpdateServerStatus(bool status) { @@ -125,7 +126,7 @@ public void UpdateConnectionStatus(bool status, TextBlock? textBlock, Button? bu public bool SaveSubTier(AppDbContext db, SubathonEventType type, string meta, TextBox tb, TextBox tb2) { var hasUpdated = false; SubathonValue? val = db.SubathonValues.FirstOrDefault(sv => sv.EventType == type && sv.Meta == meta); - if (val != null && double.TryParse(tb.Text, out double seconds) && !seconds.Equals(val.Seconds)) { + if (val != null && Utils.TryParseAmount(tb.Text, out double seconds) && !seconds.Equals(val.Seconds)) { val.Seconds = seconds; hasUpdated = true; } diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml.cs index 6a12720a..d3e0eeaa 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml.cs @@ -33,13 +33,13 @@ public override bool UpdateValueSettings(AppDbContext db) { var hasUpdated = false; SubathonValue? blerpBitsValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.BlerpBits && sv.Meta == ""); - if (blerpBitsValue != null && double.TryParse(BitsTextBox.Text, out double bitsSeconds) && + if (blerpBitsValue != null && Utils.TryParseAmount(BitsTextBox.Text, out double bitsSeconds) && !bitsSeconds.Equals(blerpBitsValue.Seconds / 100.0)) { blerpBitsValue.Seconds = bitsSeconds / 100.0; hasUpdated = true; } - if (blerpBitsValue != null && double.TryParse(Bits2TextBox.Text, out double bitsPoints) && + if (blerpBitsValue != null && Utils.TryParseAmount(Bits2TextBox.Text, out double bitsPoints) && !bitsPoints.Equals(blerpBitsValue.Points)) { blerpBitsValue.Points = bitsPoints; hasUpdated = true; @@ -47,13 +47,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? blerpBeetsValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.BlerpBits && sv.Meta == ""); - if (blerpBeetsValue != null && double.TryParse(BeetsTextBox.Text, out double beetsSeconds) && + if (blerpBeetsValue != null && Utils.TryParseAmount(BeetsTextBox.Text, out double beetsSeconds) && !beetsSeconds.Equals(blerpBeetsValue.Seconds / 100.0)) { blerpBeetsValue.Seconds = beetsSeconds / 100.0; hasUpdated = true; } - if (blerpBeetsValue != null && double.TryParse(Beets2TextBox.Text, out double beetsPoints) && + if (blerpBeetsValue != null && Utils.TryParseAmount(Beets2TextBox.Text, out double beetsPoints) && !beetsPoints.Equals(blerpBeetsValue.Points)) { blerpBeetsValue.Points = beetsPoints; hasUpdated = true; @@ -89,9 +89,9 @@ public override (string, string, TextBox?, TextBox?) GetValueBoxes(SubathonValue public bool SaveConfigValues() { var hasUpdated = false; var config = AppServices.Provider.GetRequiredService(); - if (double.TryParse(BitsModifierTextBox.Text, out double blerpBitsMod)) + if (Utils.TryParseAmount(BitsModifierTextBox.Text, out double blerpBitsMod)) hasUpdated |= config.Set("Extensions", "BlerpBits.Modifier", $"{blerpBitsMod}"); - if (double.TryParse(BeetsModifierTextBox.Text, out double blerpBeetsMod)) + if (Utils.TryParseAmount(BeetsModifierTextBox.Text, out double blerpBeetsMod)) hasUpdated |= config.Set("Extensions", "BlerpBeets.Modifier", $"{blerpBeetsMod}"); hasUpdated |= config.SetBool("Extensions", "Blerp.Enabled", EnabledBox.IsChecked ?? true); return hasUpdated; @@ -99,9 +99,9 @@ public bool SaveConfigValues() { public void LoadConfigValues() { var config = AppServices.Provider.GetRequiredService(); - double.TryParse(config.Get("Extensions", "BlerpBits.Modifier", "1"), out double blerpBitsMod); + Utils.TryParseAmount(config.Get("Extensions", "BlerpBits.Modifier", "1"), out double blerpBitsMod); BitsModifierTextBox.Text = $"{blerpBitsMod}"; - double.TryParse(config.Get("Extensions", "BlerpBeets.Modifier", "1"), out double blerpBeetsMod); + Utils.TryParseAmount(config.Get("Extensions", "BlerpBeets.Modifier", "1"), out double blerpBeetsMod); BeetsModifierTextBox.Text = $"{blerpBeetsMod}"; EnabledBox.IsChecked = config.GetBool("Extensions", "Blerp.Enabled", true); } diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/StreamElementsSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/Extensions/StreamElementsSettings.axaml.cs index dc77184a..1046657f 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/StreamElementsSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/StreamElementsSettings.axaml.cs @@ -42,13 +42,13 @@ public override bool UpdateValueSettings(AppDbContext db) { var hasUpdated = false; SubathonValue? seTipValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.StreamElementsDonation && sv.Meta == ""); - if (seTipValue != null && double.TryParse(DonoBox.Text, out double seTipSeconds) && + if (seTipValue != null && Utils.TryParseAmount(DonoBox.Text, out double seTipSeconds) && !seTipSeconds.Equals(seTipValue.Seconds)) { seTipValue.Seconds = seTipSeconds; hasUpdated = true; } - if (seTipValue != null && double.TryParse(DonoBox2.Text, out double seTipPoints) && + if (seTipValue != null && Utils.TryParseAmount(DonoBox2.Text, out double seTipPoints) && !seTipPoints.Equals(seTipValue.Points)) { seTipValue.Points = seTipPoints; hasUpdated = true; diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/StreamLabsSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/Extensions/StreamLabsSettings.axaml.cs index 1907adea..25f5d883 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/StreamLabsSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/StreamLabsSettings.axaml.cs @@ -42,13 +42,13 @@ public override bool UpdateValueSettings(AppDbContext db) { var hasUpdated = false; SubathonValue? slTipValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.StreamLabsDonation && sv.Meta == ""); - if (slTipValue != null && double.TryParse(DonoBox.Text, out double slTipSeconds) && + if (slTipValue != null && Utils.TryParseAmount(DonoBox.Text, out double slTipSeconds) && !slTipSeconds.Equals(slTipValue.Seconds)) { slTipValue.Seconds = slTipSeconds; hasUpdated = true; } - if (slTipValue != null && double.TryParse(DonoBox2.Text, out double slTipPoints) && + if (slTipValue != null && Utils.TryParseAmount(DonoBox2.Text, out double slTipPoints) && !slTipPoints.Equals(slTipValue.Points)) { slTipValue.Points = slTipPoints; hasUpdated = true; diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/TangiaSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/Extensions/TangiaSettings.axaml.cs index 8d31bcb7..8d5a673a 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/TangiaSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/TangiaSettings.axaml.cs @@ -69,13 +69,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? tokensValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.TangiaTokens && sv.Meta == ""); - if (tokensValue != null && double.TryParse(TokensSecondsBox.Text, out double seconds) && + if (tokensValue != null && Utils.TryParseAmount(TokensSecondsBox.Text, out double seconds) && !seconds.Equals(Math.Round(tokensValue.Seconds * 100))) { tokensValue.Seconds = seconds / 100.0; hasUpdated = true; } - if (tokensValue != null && double.TryParse(TokensPointsBox.Text, out double points) && + if (tokensValue != null && Utils.TryParseAmount(TokensPointsBox.Text, out double points) && !points.Equals(tokensValue.Points)) { tokensValue.Points = points; hasUpdated = true; diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.axaml.cs index 125a7c66..a0053a15 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.axaml.cs @@ -58,12 +58,12 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? tipValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.TipeeeStreamDonation && sv.Meta == ""); if (tipValue != null) { - if (double.TryParse(DonoBox.Text, out double s) && !s.Equals(tipValue.Seconds)) { + if (Utils.TryParseAmount(DonoBox.Text, out double s) && !s.Equals(tipValue.Seconds)) { tipValue.Seconds = s; hasUpdated = true; } - if (double.TryParse(DonoBox2.Text, out double p) && !p.Equals(tipValue.Points)) { + if (Utils.TryParseAmount(DonoBox2.Text, out double p) && !p.Equals(tipValue.Points)) { tipValue.Points = p; hasUpdated = true; } diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/TreatStreamSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/Extensions/TreatStreamSettings.axaml.cs index 3b0c0e0d..a0871b39 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/TreatStreamSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/TreatStreamSettings.axaml.cs @@ -67,13 +67,13 @@ public override bool UpdateValueSettings(AppDbContext db) { var hasUpdated = false; SubathonValue? treatValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.TreatStreamOrder && sv.Meta == ""); - if (treatValue != null && double.TryParse(TreatBox.Text, out double seconds) && + if (treatValue != null && Utils.TryParseAmount(TreatBox.Text, out double seconds) && !seconds.Equals(treatValue.Seconds)) { treatValue.Seconds = seconds; hasUpdated = true; } - if (treatValue != null && double.TryParse(TreatBox2.Text, out double points) && + if (treatValue != null && Utils.TryParseAmount(TreatBox2.Text, out double points) && !points.Equals(treatValue.Points)) { treatValue.Points = points; hasUpdated = true; diff --git a/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml.cs index 343aa348..ae608b9c 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml.cs @@ -38,13 +38,13 @@ public override bool UpdateValueSettings(AppDbContext db) { var hasUpdated = false; SubathonValue? externalDonoValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.ExternalDonation && sv.Meta == ""); - if (externalDonoValue != null && double.TryParse(DonoBox.Text, out double exSeconds) && + if (externalDonoValue != null && Utils.TryParseAmount(DonoBox.Text, out double exSeconds) && !exSeconds.Equals(externalDonoValue.Seconds)) { externalDonoValue.Seconds = exSeconds; hasUpdated = true; } - if (externalDonoValue != null && double.TryParse(DonoBox2.Text, out double exPoints) && + if (externalDonoValue != null && Utils.TryParseAmount(DonoBox2.Text, out double exPoints) && !exPoints.Equals(externalDonoValue.Points)) { externalDonoValue.Points = exPoints; hasUpdated = true; @@ -52,13 +52,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? defaultSubValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.ExternalSub && sv.Meta == "DEFAULT"); - if (defaultSubValue != null && double.TryParse(SubDTextBox.Text, out double defaultSeconds) && + if (defaultSubValue != null && Utils.TryParseAmount(SubDTextBox.Text, out double defaultSeconds) && !defaultSeconds.Equals(defaultSubValue.Seconds)) { defaultSubValue.Seconds = defaultSeconds; hasUpdated = true; } - if (defaultSubValue != null && double.TryParse(SubDTextBox2.Text, out double defaultPoints) && + if (defaultSubValue != null && Utils.TryParseAmount(SubDTextBox2.Text, out double defaultPoints) && !defaultPoints.Equals(defaultSubValue.Points)) { defaultSubValue.Points = defaultPoints; hasUpdated = true; @@ -82,8 +82,8 @@ public override bool UpdateValueSettings(AppDbContext db) { if (meta == "DEFAULT") continue; - if (!double.TryParse(subRow.TimeBox.Text, out double seconds)) seconds = 0; - if (!double.TryParse(subRow.PointsBox.Text, out double points)) points = 0; + if (!Utils.TryParseAmount(subRow.TimeBox.Text, out double seconds)) seconds = 0; + if (!Utils.TryParseAmount(subRow.PointsBox.Text, out double points)) points = 0; #pragma warning disable CA1862 SubathonValue? existing = db.SubathonValues.FirstOrDefault(sv => diff --git a/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml b/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml index 79680b67..280377e8 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml @@ -49,6 +49,9 @@ x:Name="ConnectBtn" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +