From 2aa7ebd7827ff83fa4969ad26bc8458073b8fcec Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:54:30 -0300 Subject: [PATCH 01/23] add impl for retrieving missed events from select services --- .../Interfaces/IMissedEventSource.cs | 7 + .../FourthWallService.cs | 238 ++++++++++----- .../GoAffProService.cs | 47 +-- SubathonManager.Integration/TiltifyService.cs | 77 +++-- .../FourthWallUnitTests.cs | 12 +- .../Views/ImportMissedWindow.axaml | 111 +++++++ .../Views/ImportMissedWindow.axaml.cs | 285 ++++++++++++++++++ .../External/FourthWallSettings.axaml | 3 + .../External/FourthWallSettings.axaml.cs | 9 +- .../External/GoAffProSettings.axaml | 4 + .../External/GoAffProSettings.axaml.cs | 5 + .../External/TiltifySettings.axaml | 3 + .../External/TiltifySettings.axaml.cs | 5 + 13 files changed, 676 insertions(+), 130 deletions(-) create mode 100644 SubathonManager.Core/Interfaces/IMissedEventSource.cs create mode 100644 SubathonManager.UI/Views/ImportMissedWindow.axaml create mode 100644 SubathonManager.UI/Views/ImportMissedWindow.axaml.cs diff --git a/SubathonManager.Core/Interfaces/IMissedEventSource.cs b/SubathonManager.Core/Interfaces/IMissedEventSource.cs new file mode 100644 index 0000000..6f8439d --- /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.Integration/FourthWallService.cs b/SubathonManager.Integration/FourthWallService.cs index 122a3a5..f4063f6 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; } @@ -315,8 +317,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 +324,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 +430,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 d8618b8..6f2d1e4 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/TiltifyService.cs b/SubathonManager.Integration/TiltifyService.cs index c35c933..ba6ec9a 100644 --- a/SubathonManager.Integration/TiltifyService.cs +++ b/SubathonManager.Integration/TiltifyService.cs @@ -24,7 +24,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 +260,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 +300,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] @@ -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,16 +374,31 @@ 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) { diff --git a/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs b/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs index f398146..39bbd24 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.UI/Views/ImportMissedWindow.axaml b/SubathonManager.UI/Views/ImportMissedWindow.axaml new file mode 100644 index 0000000..8e723f5 --- /dev/null +++ b/SubathonManager.UI/Views/ImportMissedWindow.axaml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml.cs b/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml.cs new file mode 100644 index 0000000..bb5f1d5 --- /dev/null +++ b/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml.cs @@ -0,0 +1,266 @@ +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 readonly CustomAction _action; + private readonly ILogger? _logger = AppServices.Provider.GetService>(); + private readonly ObservableCollection _nodes = []; + private readonly ObservableCollection _connections = []; + + private readonly Dictionary _targetIds = new(StringComparer.OrdinalIgnoreCase); + private IReadOnlyList? _mixItUpCommands; + private IReadOnlyList? _streamerBotActions; + + private bool _dirty; + private string _savedState = ""; + private readonly List<(string Group, Expander Section, StackPanel Buttons)> _paletteSections = []; + private readonly HashSet _collapsedGroups = new(StringComparer.Ordinal); + private bool _closeConfirmed; + private int _suppress; + + private static readonly Dictionary OpenEditors = []; + + public ActionEditorWindow() : this(new CustomAction()) { + } + + 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; + } + + 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(); + 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 }) + box.TextChanged += (_, _) => ApplyStepFields(); + foreach (AutoCompleteBox box in new[] { ScopeBox, TargetBox }) + box.TextChanged += (_, _) => ApplyStepFields(); + ScopeBox.LostFocus += (_, _) => _ = LoadTargetSuggestionsAsync(); + ScopeBox.SelectionChanged += (_, _) => _ = 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; + + 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 ?? ""; + + RepeatBox.Items.Clear(); + foreach ((ActionRepeatMode mode, string label) in new[] { + (ActionRepeatMode.Restart, "Restart it"), + (ActionRepeatMode.Parallel, "Run another in parallel"), + (ActionRepeatMode.Skip, "Ignore the new one") + }) + RepeatBox.Items.Add(new ComboBoxItem { Content = label, Tag = mode }); + RepeatBox.SelectedItem = RepeatBox.Items.OfType() + .FirstOrDefault(i => (ActionRepeatMode?)i.Tag == Graph.OnRepeat); + } + 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 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 { + 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; + } + + StatusText.Foreground = Brushes.Gray; + StatusText.Text = "Test run started..."; + CustomAction snapshot = _action.Clone(); + ActionRunResult result = await ServiceManager.Actions.RunManuallyAsync(snapshot, unsavedCopy: true); + 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(); + } +} diff --git a/SubathonManager.UI/Views/Actions/ActionsView.axaml b/SubathonManager.UI/Views/Actions/ActionsView.axaml new file mode 100644 index 0000000..43bdb11 --- /dev/null +++ b/SubathonManager.UI/Views/Actions/ActionsView.axaml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SubathonManager.UI/Views/Actions/ActionsView.axaml.cs b/SubathonManager.UI/Views/Actions/ActionsView.axaml.cs new file mode 100644 index 0000000..5426d45 --- /dev/null +++ b/SubathonManager.UI/Views/Actions/ActionsView.axaml.cs @@ -0,0 +1,296 @@ +using System.ComponentModel; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using FluentAvalonia.UI.Controls; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Objects; +using SubathonManager.Data; +using SubathonManager.Services; +using SubathonManager.UI.Services; +using SubathonManager.UI.UiUtils; + +namespace SubathonManager.UI.Views.Actions; + +public partial class ActionsView : UserControl { + private readonly IDbContextFactory _factory = + AppServices.Provider.GetRequiredService>(); + + private readonly ILogger? _logger = AppServices.Provider.GetService>(); + private List _cards = []; + private Guid? _selectedId; + + public ActionsView() { + InitializeComponent(); + SearchBox.TextChanged += (_, _) => Refresh(); + Loaded += (_, _) => { + ServiceManager.Actions.CustomActionsChanged -= OnLibraryChanged; + ServiceManager.Actions.CustomActionsChanged += OnLibraryChanged; + Refresh(); + }; + Unloaded += (_, _) => ServiceManager.Actions.CustomActionsChanged -= OnLibraryChanged; + } + + private void OnLibraryChanged() { + Dispatcher.UIThread.Post(Refresh); + } + + private void Refresh() { + IReadOnlyList all = ServiceManager.Actions.CustomActions; + string query = (SearchBox.Text ?? "").Trim(); + List cards = all + .Where(a => query.Length == 0 + || a.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || (a.Author?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) + || (a.Description?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false)) + .Select(a => new ActionCard(a) { IsSelected = a.Id == _selectedId }) + .ToList(); + + _cards = cards; + ActionsList.ItemsSource = cards; + EmptyText.IsVisible = cards.Count == 0; + EmptyText.Text = all.Count == 0 + ? "No actions yet. Create one, import a .sma file, or convert a VTube Studio / OBS wheel item" + : $"Nothing matches \"{query}\""; + ShowDetails(); + } + + public void ShowAdded(CustomAction? action, string source) { + if (action == null) { + StatusText.Text = $"{Path.GetFileName(source)} is not a valid custom action"; + return; + } + + SearchBox.Text = ""; + _selectedId = action.Id; + Refresh(); + StatusText.Text = $"Added \"{action.Name}\" to your actions"; + } + + private static CustomAction? CardOf(object? sender) { + return (sender as Control)?.DataContext is ActionCard card ? card.Action : null; + } + + private void Card_Tapped(object? sender, TappedEventArgs e) { + if (CardOf(sender) is not { } action) return; + _selectedId = action.Id; + foreach (ActionCard card in _cards) card.IsSelected = card.Action.Id == action.Id; + ShowDetails(); + } + + private void ShowDetails() { + CustomAction? action = _selectedId is { } id ? ServiceManager.Actions.GetCustomAction(id) : null; + DetailsPanel.IsVisible = action != null; + NoSelectionText.IsVisible = action == null; + if (action == null) { + _selectedId = null; + return; + } + + DetailTitle.Text = action.Name; + + var meta = new List { $"v{action.Version}" }; + if (!string.IsNullOrWhiteSpace(action.Author)) meta.Add($"by {action.Author}"); + string? path = ServiceManager.Actions.GetCustomActionPath(action.Id); + if (path != null && File.Exists(path)) meta.Add($"updated {File.GetLastWriteTime(path):yyyy-MM-dd HH:mm}"); + DetailMeta.Text = string.Join(" - ", meta); + + DetailSteps.Text = $"{action.Graph.Nodes.Count}"; + DetailBranches.Text = $"{CountBranches(action.Graph)}"; + DetailError.Text = action.Graph.IsValid(out string error) ? "" : $"Needs fixing: {error}"; + DetailError.IsVisible = DetailError.Text.Length > 0; + DetailDescription.Text = string.IsNullOrWhiteSpace(action.Description) ? "No description." : action.Description; + } + + private static int CountBranches(ActionGraph graph) { + if (graph.Nodes.Count == 0 || !graph.IsValid(out _)) return 0; + var paths = new Dictionary(StringComparer.Ordinal); + + int PathsFrom(string id) { + if (paths.TryGetValue(id, out int known)) return known; + List next = graph.Outgoing(id).ToList(); + int count = next.Count == 0 ? 1 : next.Sum(PathsFrom); + paths[id] = count; + return count; + } + + return graph.Nodes.Where(n => !graph.Incoming(n.Id).Any()).Sum(n => PathsFrom(n.Id)); + } + + private async void Run_Click(object? sender, RoutedEventArgs e) { + if (CardOf(sender) is { } action) await RunAsync(action); + } + + private async void RunSelected_Click(object? sender, RoutedEventArgs e) { + if (_selectedId is { } id && ServiceManager.Actions.GetCustomAction(id) is { } action) await RunAsync(action); + } + + private async Task RunAsync(CustomAction action) { + if (!action.Graph.IsValid(out string error) || action.Graph.Nodes.Count == 0) { + StatusText.Text = action.Graph.Nodes.Count == 0 + ? $"\"{action.Name}\" has no steps yet" + : $"\"{action.Name}\" can't run yet: {error}"; + return; + } + + StatusText.Text = $"Running \"{action.Name}\"..."; + ActionRunResult result = await ServiceManager.Actions.RunManuallyAsync(action); + StatusText.Text = result switch { + ActionRunResult.Done => $"\"{action.Name}\" finished", + ActionRunResult.Skipped => $"\"{action.Name}\" is already running and ignores repeats", + ActionRunResult.Cancelled => $"\"{action.Name}\" was cancelled", + _ => $"\"{action.Name}\" stopped at a step that could not run (is the app connected?) - see the log" + }; + } + + private void EditSelected_Click(object? sender, RoutedEventArgs e) { + if (_selectedId is { } id && ServiceManager.Actions.GetCustomAction(id) is { } action) + OpenEditor(action); + } + + private void OpenEditor(CustomAction action) { + ActionEditorWindow editor = ActionEditorWindow.Open(action); + editor.Saved -= OnEditorSaved; + editor.Saved += OnEditorSaved; + } + + private void OnEditorSaved(Guid id) { + _selectedId = id; + Refresh(); + } + + private void Card_DoubleTapped(object? sender, TappedEventArgs e) { + if (CardOf(sender) is { } action) OpenEditor(action); + } + + private void Edit_Click(object? sender, RoutedEventArgs e) { + if (CardOf(sender) is { } action) OpenEditor(action); + } + + private void CreateNew_Click(object? sender, RoutedEventArgs e) { + OpenEditor(new CustomAction { Name = "New Action" }); + } + + private async void Duplicate_Click(object? sender, RoutedEventArgs e) { + if (CardOf(sender) is not { } original) return; + CustomAction copy = await ServiceManager.Actions.DuplicateCustomActionAsync(original); + _selectedId = copy.Id; + Refresh(); + StatusText.Text = $"Duplicated \"{original.Name}\" as \"{copy.Name}\""; + } + + private async void Export_Click(object? sender, RoutedEventArgs e) { + if (CardOf(sender) is not { } action) return; + try { + string path = await ActionService.ExportCustomActionAsync(action); + StatusText.Text = $"Exported to {path}"; + UiHelpers.OpenFolder(Path.GetDirectoryName(path)); + } + catch (Exception ex) { + _logger?.LogError(ex, "[Actions] Export of {Name} failed", action.Name); + StatusText.Text = $"Export failed: {ex.Message}"; + } + } + + private async void Delete_Click(object? sender, RoutedEventArgs e) { + if (CardOf(sender) is not { } action) return; + + string id = action.Id.ToString(); + await using AppDbContext db = await _factory.CreateDbContextAsync(); + + // TODO when more than just wheel spins, check + int uses = await db.WheelSpinActions + .CountAsync(a => a.ActionType == WheelSpinActionType.CustomAction && a.Parameter == id); + + var dialog = new FAContentDialog { + Title = "Delete action", + Content = uses > 0 + ? $"Delete \"{action.Name}\"? {uses} wheel item(s) use it and will stop running anything until you pick another action." + : $"Delete \"{action.Name}\"? Its .sma file is removed from the actions folder.", + PrimaryButtonText = "Delete", + CloseButtonText = "Cancel" + }; + if (await dialog.ShowAsync() != FAContentDialogResult.Primary) return; + + ServiceManager.Actions.DeleteCustomAction(action.Id); + StatusText.Text = $"Deleted \"{action.Name}\""; + } + + private async void Import_Click(object? sender, RoutedEventArgs e) { + if (TopLevel.GetTopLevel(this) is not { } top) return; + IReadOnlyList picked = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { + Title = "Import Custom Action", + AllowMultiple = true, + FileTypeFilter = [ + new FilePickerFileType("Subathon Manager Action") { Patterns = [$"*{CustomAction.FileExtension}"] } + ] + }); + if (picked.Count == 0) return; + + int added = 0, updated = 0, failed = 0; + foreach (IStorageFile file in picked) { + (CustomAction Action, bool Replaced)? result = + await ServiceManager.Actions.ImportCustomActionAsync(file.Path.LocalPath); + if (result == null) failed++; + else if (result.Value.Replaced) updated++; + else added++; + } + + StatusText.Text = $"Imported {added}, updated {updated}" + (failed > 0 ? $", {failed} not valid" : ""); + } + + private void OpenActionsFolder_Click(object? sender, RoutedEventArgs e) { + OpenFolder(ActionService.DefaultActionsFolder); + } + + private void OpenExportsFolder_Click(object? sender, RoutedEventArgs e) { + OpenFolder(ActionService.ExportsFolder); + } + + private async void Reload_Click(object? sender, RoutedEventArgs e) { + await ServiceManager.Actions.LoadLibraryAsync(); + StatusText.Text = $"Loaded {ServiceManager.Actions.CustomActions.Count} action(s)"; + } + + private void OpenFolder(string path) { + try { + Directory.CreateDirectory(path); + if (!UiHelpers.OpenFolder(path)) _logger?.LogWarning("[Actions] Unable to open {Path}", path); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[Actions] Unable to open {Path}", path); + } + } +} + +public sealed class ActionCard(CustomAction action) : INotifyPropertyChanged { + private bool _isSelected; + + public CustomAction Action { get; } = action; + + public bool IsSelected { + get => _isSelected; + set { + if (_isSelected == value) return; + _isSelected = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public string Name => Action.Name; + public string? Description => Action.Description; + public bool HasDescription => !string.IsNullOrWhiteSpace(Action.Description); + + public string Detail => + $"{Action.Graph.Nodes.Count} step(s) - v{Action.Version}" + + (string.IsNullOrWhiteSpace(Action.Author) ? "" : $" - by {Action.Author}") + + (Action.Graph.IsValid(out _) ? "" : " - needs fixing"); +} diff --git a/SubathonManager.UI/Views/GoalsEditor.axaml.cs b/SubathonManager.UI/Views/GoalsEditor.axaml.cs index 2403397..d705847 100644 --- a/SubathonManager.UI/Views/GoalsEditor.axaml.cs +++ b/SubathonManager.UI/Views/GoalsEditor.axaml.cs @@ -1,4 +1,3 @@ -using System.Text; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; @@ -619,28 +618,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 +640,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 +658,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 +687,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/Prompts/PromptsEditor.axaml.cs b/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs index fdc638d..352ebbd 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; @@ -83,12 +82,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"; @@ -937,38 +938,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 +966,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 +975,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 +989,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 +1077,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", diff --git a/SubathonManager.UI/Views/Schedule/ScheduleView.Csv.cs b/SubathonManager.UI/Views/Schedule/ScheduleView.Csv.cs index 3f39faf..f308df6 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/SettingsView.Settings.cs b/SubathonManager.UI/Views/SettingsView.Settings.cs index 66e14f7..0cb4494 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) { diff --git a/SubathonManager.UI/Views/SettingsViews/External/PatreonSettings.axaml b/SubathonManager.UI/Views/SettingsViews/External/PatreonSettings.axaml index 3a695db..2c9f27d 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/PatreonSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/External/PatreonSettings.axaml @@ -54,7 +54,7 @@ + Text="New pledges, renewals, and upgrades add based on the amount charged or by tier" /> diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.axaml b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.axaml index 69cfad4..8707c46 100644 --- a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.axaml @@ -3,6 +3,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:views="using:SubathonManager.UI.Views" + xmlns:ui2="using:SubathonManager.UI.UiUtils" mc:Ignorable="d" x:Class="SubathonManager.UI.Views.SettingsViews.ExternalSoftware.StreamerBotSettings"> @@ -10,11 +11,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + - - - + - - - - - - - - - - - - - + + + + - - - - + + + + - - - - + + + + + + + + + + - + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + - - - - - + + + + + + + + + + + + + + + - + + + + + + + + + + @@ -315,4 +391,4 @@ - + \ No newline at end of file diff --git a/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml.cs b/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml.cs index bb5f1d5..ee0b8a4 100644 --- a/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml.cs +++ b/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml.cs @@ -20,43 +20,27 @@ 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 ObservableCollection _connections = []; + private readonly List<(string Group, Expander Section, StackPanel Buttons)> _paletteSections = []; private readonly Dictionary _targetIds = new(StringComparer.OrdinalIgnoreCase); - private IReadOnlyList? _mixItUpCommands; - private IReadOnlyList? _streamerBotActions; + private bool _closeConfirmed; private bool _dirty; + private IReadOnlyList? _mixItUpCommands; private string _savedState = ""; - private readonly List<(string Group, Expander Section, StackPanel Buttons)> _paletteSections = []; - private readonly HashSet _collapsedGroups = new(StringComparer.Ordinal); - private bool _closeConfirmed; + private IReadOnlyList? _streamerBotActions; private int _suppress; - private static readonly Dictionary OpenEditors = []; - public ActionEditorWindow() : this(new CustomAction()) { } - 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; - } - public ActionEditorWindow(CustomAction action) { InitializeComponent(); WindowIcons.Apply(this); @@ -93,10 +77,13 @@ public ActionEditorWindow(CustomAction action) { MarkDirty(); if (box == NameBox) Title = $"Custom Action - {NameBox.Text}"; }; - foreach (TextBox box in new[] { ValueBox, DurationBox, BodyBox }) + 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 += (_, _) => _ = LoadTargetSuggestionsAsync(); @@ -110,10 +97,28 @@ public ActionEditorWindow(CustomAction action) { } 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 { @@ -204,7 +209,9 @@ private async Task SaveAsync() { ResetDirty(); Validate(); - StatusText.Text = Graph.IsValid(out string error) ? "Saved" : $"Save - Will not run until errors are fixed: {error}"; + StatusText.Text = Graph.IsValid(out string error) + ? "Saved" + : $"Save - Will not run until errors are fixed: {error}"; Saved?.Invoke(_action.Id); return true; @@ -221,7 +228,7 @@ private async void TestRun_Click(object? sender, RoutedEventArgs e) { StatusText.Foreground = Brushes.Gray; StatusText.Text = "Test run started..."; CustomAction snapshot = _action.Clone(); - ActionRunResult result = await ServiceManager.Actions.RunManuallyAsync(snapshot, unsavedCopy: true); + ActionRunResult result = await ServiceManager.Actions.RunManuallyAsync(snapshot, true); StatusText.Text = result switch { ActionRunResult.Done => "Test run finished", ActionRunResult.Skipped => "Test run skipped: one is already running and this action ignores repeats", @@ -263,4 +270,4 @@ private async void Window_Closing(object? sender, WindowClosingEventArgs e) { _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 0000000..1e8933f --- /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.axaml b/SubathonManager.UI/Views/Actions/ActionsView.axaml index 43bdb11..18fe6d4 100644 --- a/SubathonManager.UI/Views/Actions/ActionsView.axaml +++ b/SubathonManager.UI/Views/Actions/ActionsView.axaml @@ -147,10 +147,10 @@ - + - + - + @@ -188,7 +188,7 @@ - + @@ -198,8 +198,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -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 352ebbd..8300f7c 100644 --- a/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs +++ b/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs @@ -48,6 +48,7 @@ public PromptsEditor() { if (run.Status == SubathonPromptRunStatus.Completed) LoadPromptRows(); }); + InitHistory(); Loaded += (_, _) => { if (!_initialized) { @@ -242,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) { @@ -652,6 +654,7 @@ SubathonEventType.MakeShipPledge or SubathonEventType.MakeShipSale } UpdateValueLabel(prompt.Type, prompt.SubType); + PopulatePromptActionBox(prompt.CustomActionId); }); RefreshRowHighlights(clickedRow); @@ -820,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; @@ -851,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() { @@ -1125,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; } From 6df280d2fcc647b6e77477ba1cdbdf965542a01b Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:29:53 -0300 Subject: [PATCH 09/23] minor cleanup --- SubathonManager.Core/Enums/ActionEnums.cs | 28 +++++++++---------- .../Actions/ActionEditorWindow.Properties.cs | 2 +- .../Views/Actions/ActionEditorWindow.axaml | 2 +- .../Views/Actions/ActionsView.axaml | 4 +-- .../Views/Actions/ActionsView.axaml.cs | 3 +- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/SubathonManager.Core/Enums/ActionEnums.cs b/SubathonManager.Core/Enums/ActionEnums.cs index a283b04..55c4256 100644 --- a/SubathonManager.Core/Enums/ActionEnums.cs +++ b/SubathonManager.Core/Enums/ActionEnums.cs @@ -194,15 +194,15 @@ public enum ActionRepeatMode { public enum ActionVariable { [ActionVariableMeta(Token = "seconds_remaining", Group = "Timer", ValueType = "number", - Description = "Whole seconds left on the timer")] + Description = "Seconds left on the timer")] SecondsRemaining, [ActionVariableMeta(Token = "time_remaining", Group = "Timer", ValueType = "text", - Description = "Time left as hours:minutes:seconds, e.g. 26:04:59")] + Description = "Time left as hh:mm:ss, e.g. 26:04:59")] TimeRemaining, [ActionVariableMeta(Token = "seconds_elapsed", Group = "Timer", ValueType = "number", - Description = "Whole seconds the timer has run")] + Description = "Seconds the timer has run")] SecondsElapsed, [ActionVariableMeta(Token = "timer_paused", Group = "Timer", ValueType = "true/false", @@ -210,19 +210,19 @@ public enum ActionVariable { TimerPaused, [ActionVariableMeta(Token = "timer_locked", Group = "Timer", ValueType = "true/false", - Description = "Whether the subathon is locked to new events")] + Description = "Whether the subathon is locked")] TimerLocked, [ActionVariableMeta(Token = "timer_reversed", Group = "Timer", ValueType = "true/false", - Description = "Whether the timer counts up instead of down")] + Description = "Is the timer is in reverse mode?")] TimerReversed, [ActionVariableMeta(Token = "multiplier_active", Group = "Multiplier", ValueType = "true/false", - Description = "Whether a multiplier is running")] + Description = "Whether a multiplier is active")] MultiplierActive, [ActionVariableMeta(Token = "multiplier_amount", Group = "Multiplier", ValueType = "number", - Description = "Current multiplier, 1 when none is running")] + Description = "Current multiplier amount, 1 if inactive")] MultiplierAmount, [ActionVariableMeta(Token = "multiplier_seconds_remaining", Group = "Multiplier", ValueType = "number", @@ -251,11 +251,11 @@ public enum ActionVariable { // 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")] + Description = "The primary currency from settings, should match subathon currency")] DefaultCurrency, [ActionVariableMeta(Token = "goal_text", Group = "Goals", ValueType = "text", - Description = "The goal currently being worked towards, empty once all are done")] + Description = "The current unfinished goal, empty once all are done")] GoalText, [ActionVariableMeta(Token = "goal_points", Group = "Goals", ValueType = "number", @@ -263,20 +263,20 @@ public enum ActionVariable { GoalPoints, [ActionVariableMeta(Token = "goal_index", Group = "Goals", ValueType = "number", - Description = "Position of the current goal in the list, starting at 1; 0 when all are done")] + 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 the active list has")] + 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 goals")] + Description = "Points (or whole money) counted towards all goals")] GoalProgress, [ActionVariableMeta(Token = "last_goal_text", Group = "Goals", ValueType = "text", - Description = "The most recently completed goal, empty if none yet")] + Description = "Most recently completed goal, empty if none yet")] LastGoalText, [ActionVariableMeta(Token = "last_goal_points", Group = "Goals", ValueType = "number", @@ -288,7 +288,7 @@ public enum ActionVariable { GoalListName, [ActionVariableMeta(Token = "goal_type", Group = "Goals", ValueType = "text", - Description = "What the active goal list counts: Points or Money")] + Description = "Are goals tracking `Points` or `Money`?")] GoalType, [ActionVariableMeta(Token = "user", Group = "Action", ValueType = "text", diff --git a/SubathonManager.UI/Views/Actions/ActionEditorWindow.Properties.cs b/SubathonManager.UI/Views/Actions/ActionEditorWindow.Properties.cs index e1e2150..4a1254f 100644 --- a/SubathonManager.UI/Views/Actions/ActionEditorWindow.Properties.cs +++ b/SubathonManager.UI/Views/Actions/ActionEditorWindow.Properties.cs @@ -545,7 +545,7 @@ private async Task LoadStoredAsync() { if (_globalSection != null) SetVariableRows(_globalSection, _stored.Where(g => g.Kind == ActionStoreKind.Global).Select(g => ( ActionStepTypeHelper.StorePlaceholder(g.Kind, g.Name), g.ValueType.GetLabel(), - g.Value == null ? "No value yet" : $"Now: {Shorten(g.Value)}", "global"))); + g.Value == null ? "No value yet" : $"Current: {Shorten(g.Value)}", "global"))); if (_secretSection != null) SetVariableRows(_secretSection, _stored.Where(g => g.Kind == ActionStoreKind.Secret).Select(g => ( ActionStepTypeHelper.StorePlaceholder(g.Kind, g.Name), "secret", diff --git a/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml b/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml index 16a5d28..e01f9de 100644 --- a/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml +++ b/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml @@ -269,7 +269,7 @@ TextWrapping="Wrap" PlaceholderText="Description..." /> + Text="Add new steps from the left. If you have an action selected, it will chain it, else it will start a new parallel branch. Drag an arrow to/from steps to create new connections and branches. If an action has multiple inputs, it waits for all paths to complete before running. Select actions or a connection and press Delete to remove them." /> diff --git a/SubathonManager.UI/Views/Actions/ActionsView.axaml b/SubathonManager.UI/Views/Actions/ActionsView.axaml index d3acd0b..7bafe1f 100644 --- a/SubathonManager.UI/Views/Actions/ActionsView.axaml +++ b/SubathonManager.UI/Views/Actions/ActionsView.axaml @@ -175,7 +175,7 @@ + Foreground="Gray" Text="Select an action to see its details here" /> - + - + + DoneBox_Changed(goal, doneBox.IsChecked == true); + var deleteBtn = new Button { Content = new SymIcon { Glyph = "Delete20", HorizontalAlignment = HorizontalAlignment.Center }, Width = 32, Height = 32, @@ -290,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); @@ -312,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(); } @@ -509,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); From d3fff985a4c0743e49c45efba0e7beffc0a998b3 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:42:00 -0300 Subject: [PATCH 11/23] allow multi run mode for multi inputs as toggle --- SubathonManager.Core/Enums/ActionEnums.cs | 5 ++ SubathonManager.Core/Objects/ActionGraph.cs | 34 ++++++++- SubathonManager.Services/ActionService.cs | 70 ++++++++++++++----- .../ServicesUnitTests/ActionServiceTests.cs | 59 ++++++++++++++++ .../Views/Actions/ActionEditorViewModels.cs | 14 ++++ .../Views/Actions/ActionEditorWindow.Graph.cs | 21 +++++- .../Actions/ActionEditorWindow.Properties.cs | 9 +++ .../Views/Actions/ActionEditorWindow.axaml | 12 +++- 8 files changed, 203 insertions(+), 21 deletions(-) diff --git a/SubathonManager.Core/Enums/ActionEnums.cs b/SubathonManager.Core/Enums/ActionEnums.cs index 55c4256..eb1c2b0 100644 --- a/SubathonManager.Core/Enums/ActionEnums.cs +++ b/SubathonManager.Core/Enums/ActionEnums.cs @@ -172,6 +172,11 @@ public enum ActionValueType { Boolean } +public enum ActionInputMode { + Wait, + Multiple +} + public enum ActionHttpAuth { None, Bearer, diff --git a/SubathonManager.Core/Objects/ActionGraph.cs b/SubathonManager.Core/Objects/ActionGraph.cs index e60fa97..7bfc25d 100644 --- a/SubathonManager.Core/Objects/ActionGraph.cs +++ b/SubathonManager.Core/Objects/ActionGraph.cs @@ -276,6 +276,9 @@ public sealed class ActionNode { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool IgnoreErrors { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public ActionInputMode Inputs { get; set; } } public sealed class ActionEdge { @@ -515,6 +518,8 @@ public void SetOutput(string name, string value, string status) { 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; } = []; @@ -550,12 +555,37 @@ public void SetPort(string nodeId, string? port) { } } - public bool IsLive(ActionEdge edge) { + private static string FireKey(string nodeId, string? port) { + return $"{nodeId}|{port}"; + } + + public void RecordRun(string nodeId, string? port) { lock (_lock) { - return Done.Contains(edge.From) && Ports.GetValueOrDefault(edge.From) == edge.Port; + 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) { diff --git a/SubathonManager.Services/ActionService.cs b/SubathonManager.Services/ActionService.cs index 73d4e3f..70d847d 100644 --- a/SubathonManager.Services/ActionService.cs +++ b/SubathonManager.Services/ActionService.cs @@ -813,7 +813,9 @@ private async Task RunGraphAsync(ActionGraph graph, ActionContext ctx, Act Dictionary pending = graph.Nodes .Where(n => !progress.IsResolved(n.Id)) .ToDictionary(n => n.Id); - var running = new Dictionary, ActionNode>(); + + 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); @@ -831,37 +833,71 @@ void StartReady() { changed = false; foreach (ActionNode node in pending.Values.ToList()) { List incoming = graph.IncomingEdges(node.Id).ToList(); - if (!incoming.All(e => progress.IsResolved(e.From))) continue; - pending.Remove(node.Id); + if (incoming.Count == 0) { + pending.Remove(node.Id); + if (StartsRun(node)) { + Start(node); + continue; + } - if (incoming.Count > 0 ? !incoming.Any(progress.IsLive) : !StartsRun(node)) { progress.MarkSkipped(node.Id); changed = true; continue; } - running[StartStep(node)] = node; + 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); } - Task StartStep(ActionNode node) { - if (node.Disabled || node.Step.Type == ActionStepType.Trigger) return Task.Run(() => true, ct); - return node.Step.Type == ActionStepType.Condition - ? RunConditionAsync(node, ctx, progress, outputs, ct) - : RunStepAsync(node.Step, ctx, progress, outputs, ct); + 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 next = await Task.WhenAny(running.Keys); + 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 = await next; + (ok, port) = await next; } catch (OperationCanceledException) { ok = false; @@ -878,7 +914,10 @@ Task StartStep(ActionNode node) { } if (ok) { - progress.MarkDone(node.Id); + 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; @@ -894,18 +933,17 @@ Task StartStep(ActionNode node) { return !failed && pending.Count == 0 && !outerCt.IsCancellationRequested; } - private async Task RunConditionAsync(ActionNode node, ActionContext ctx, ActionRunProgress progress, + 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); - progress.SetPort(node.Id, result ? null : ActionEdge.ElsePort); if (logger?.IsEnabled(LogLevel.Debug) ?? false) logger.LogDebug("[Actions] {Label}: \"{Step}\" was {Result}", ctx.Label ?? ctx.RepeatKey, node.Step.Describe(), result); - return true; + return result ? null : ActionEdge.ElsePort; } private async Task RunStepAsync(ActionStep step, ActionContext ctx, ActionRunProgress progress, diff --git a/SubathonManager.Tests/ServicesUnitTests/ActionServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/ActionServiceTests.cs index 02b3e43..552236b 100644 --- a/SubathonManager.Tests/ServicesUnitTests/ActionServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/ActionServiceTests.cs @@ -177,6 +177,65 @@ public async Task Branches_RunInParallel_AndAJoinWaitsForEveryInput() { 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(); diff --git a/SubathonManager.UI/Views/Actions/ActionEditorViewModels.cs b/SubathonManager.UI/Views/Actions/ActionEditorViewModels.cs index 6063fdc..8394557 100644 --- a/SubathonManager.UI/Views/Actions/ActionEditorViewModels.cs +++ b/SubathonManager.UI/Views/Actions/ActionEditorViewModels.cs @@ -39,6 +39,7 @@ public ActionNodeVm(ActionNode node) { 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); @@ -70,6 +71,8 @@ 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(); @@ -96,6 +99,17 @@ public sealed class ActionConnectorVm(ActionNodeVm owner, bool isInput, string? 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 { diff --git a/SubathonManager.UI/Views/Actions/ActionEditorWindow.Graph.cs b/SubathonManager.UI/Views/Actions/ActionEditorWindow.Graph.cs index 24ef278..12558ea 100644 --- a/SubathonManager.UI/Views/Actions/ActionEditorWindow.Graph.cs +++ b/SubathonManager.UI/Views/Actions/ActionEditorWindow.Graph.cs @@ -309,10 +309,19 @@ private MenuFlyout BuildNodeMenu(ActionNodeVm clicked) { toggle.Click += (_, _) => ToggleNodes(targets, !clicked.Node.Disabled); var ignore = new MenuItem { Header = "Ignore Errors" }; ignore.Click += (_, _) => SetIgnoreErrors(targets, !clicked.Node.IgnoreErrors); + var multiple = new MenuItem { + Header = "Run For Each Input", + ToggleType = MenuItemToggleType.CheckBox, + IsChecked = clicked.Node.Inputs == ActionInputMode.Multiple, + IsVisible = targets.Any(t => t.HasInput) + }; + + multiple.Click += (_, _) => SetInputMode(targets, + clicked.Node.Inputs == ActionInputMode.Multiple ? ActionInputMode.Wait : ActionInputMode.Multiple); var delete = new MenuItem { Header = $"Delete{suffix}" }; delete.Click += (_, _) => DeleteItems(targets, []); - var menu = new MenuFlyout { Items = { duplicate, toggle, ignore, new Separator(), delete } }; + var menu = new MenuFlyout { Items = { duplicate, toggle, ignore, multiple, new Separator(), delete } }; if (clicked.IsTrigger && targets.Count == 1) { var test = new MenuItem { Header = "Test Trigger" }; ToolTip.SetTip(test, "Run the action with sample trigger values"); @@ -329,6 +338,13 @@ private List MenuTargets(ActionNodeVm clicked) { return selected.Count > 1 && selected.Contains(clicked) ? selected : [clicked]; } + private void SetInputMode(List targets, ActionInputMode mode) { + foreach (ActionNodeVm node in targets.Where(t => t.HasInput)) node.Node.Inputs = mode; + RefreshNodes(); + if (SelectedVm is { } selected && targets.Contains(selected)) ShowSelection(); + MarkDirty(); + } + private void SetIgnoreErrors(List targets, bool ignore) { foreach (ActionNodeVm node in targets) node.Node.IgnoreErrors = ignore; RefreshNodes(); @@ -357,7 +373,8 @@ private void DuplicateNodes(List targets) { X = original.Node.X + 30, Y = original.Node.Y + StepSpacingY, Disabled = original.Node.Disabled, - IgnoreErrors = original.Node.IgnoreErrors + IgnoreErrors = original.Node.IgnoreErrors, + Inputs = original.Node.Inputs }; newIds[original.Node.Id] = copy.Id; Graph.Nodes.Add(copy); diff --git a/SubathonManager.UI/Views/Actions/ActionEditorWindow.Properties.cs b/SubathonManager.UI/Views/Actions/ActionEditorWindow.Properties.cs index 4a1254f..0634ee6 100644 --- a/SubathonManager.UI/Views/Actions/ActionEditorWindow.Properties.cs +++ b/SubathonManager.UI/Views/Actions/ActionEditorWindow.Properties.cs @@ -106,6 +106,8 @@ private void ShowSelection() { IgnoreErrorsCheck.IsVisible = type is not (ActionStepType.Condition or ActionStepType.Trigger); IgnoreErrorsCheck.IsChecked = node.IgnoreErrors; + MultipleInputsCheck.IsVisible = type != ActionStepType.Trigger; + MultipleInputsCheck.IsChecked = node.Inputs == ActionInputMode.Multiple; RefreshFieldVisibility(step); } finally { @@ -231,6 +233,13 @@ private void TriggerField_Changed(object? sender, RoutedEventArgs e) { MarkDirty(); } + private void MultipleInputs_Changed(object? sender, RoutedEventArgs e) { + if (_suppress > 0 || SelectedNode is not { } node) return; + node.Inputs = MultipleInputsCheck.IsChecked == true ? ActionInputMode.Multiple : ActionInputMode.Wait; + RefreshNodes(); + MarkDirty(); + } + private void IgnoreErrors_Changed(object? sender, RoutedEventArgs e) { if (_suppress > 0 || SelectedNode is not { } node) return; node.IgnoreErrors = IgnoreErrorsCheck.IsChecked == true; diff --git a/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml b/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml index e01f9de..01eac08 100644 --- a/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml +++ b/SubathonManager.UI/Views/Actions/ActionEditorWindow.axaml @@ -45,6 +45,9 @@ + + Classes.multi="{Binding IsMultiInput}" ToolTip.Tip="{Binding Tip}" /> @@ -155,6 +158,7 @@ @@ -169,6 +173,9 @@ + @@ -387,6 +394,9 @@ + From d2e475c9c718e863ba93b51c2c1b606fadf01991 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:07:05 -0300 Subject: [PATCH 12/23] fix test --- SubathonManager.Server/WebServer.Api.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/SubathonManager.Server/WebServer.Api.cs b/SubathonManager.Server/WebServer.Api.cs index 4f638ac..b3cb7ce 100644 --- a/SubathonManager.Server/WebServer.Api.cs +++ b/SubathonManager.Server/WebServer.Api.cs @@ -316,8 +316,8 @@ internal async Task HandleGlobalsRequestAsync(IHttpContext ctx) { } internal async Task HandleGlobalsSetRequestAsync(IHttpContext ctx) { - async Task Fail(int code, string error2) { - await ctx.WriteResponse(code, JsonSerializer.Serialize(new { error2 }), true, "application/json"); + async Task Fail(int code, string error) { + await ctx.WriteResponse(code, JsonSerializer.Serialize(new { error }), true, "application/json"); } var actions = AppServices.Provider?.GetService(); @@ -407,10 +407,10 @@ await Fail(400, named == null value = (-double.Parse(amount, CultureInfo.InvariantCulture)).ToString(CultureInfo.InvariantCulture); } - (ActionGlobal? global, bool created, string? error) = + (ActionGlobal? global, bool created, string? errorStr) = await actions.ChangeGlobalAsync(name, sentType, operation.Value, value, true); if (global == null) { - await Fail(400, error ?? "Could not set global"); + await Fail(400, errorStr ?? "Could not set global"); return; } From ac2123ff1d1cb8f868a77e57a9aff5ff5d2b6d5d Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:26:28 -0300 Subject: [PATCH 13/23] minor cleanup --- SubathonManager.Core/Enums/ActionEnums.cs | 5 ++- SubathonManager.Core/Objects/ActionGraph.cs | 41 +++++++++++++-------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/SubathonManager.Core/Enums/ActionEnums.cs b/SubathonManager.Core/Enums/ActionEnums.cs index eb1c2b0..6da0c93 100644 --- a/SubathonManager.Core/Enums/ActionEnums.cs +++ b/SubathonManager.Core/Enums/ActionEnums.cs @@ -156,7 +156,7 @@ public enum ActionOperation { // Set/Adjust Global on a number Adjust, - + // Start another action, do not wait for result Start } @@ -469,7 +469,8 @@ 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, + ? number + : 0d, ActionValueType.Boolean => string.Equals(value, "true", StringComparison.OrdinalIgnoreCase), _ => value ?? "" }; diff --git a/SubathonManager.Core/Objects/ActionGraph.cs b/SubathonManager.Core/Objects/ActionGraph.cs index 7bfc25d..b9b2989 100644 --- a/SubathonManager.Core/Objects/ActionGraph.cs +++ b/SubathonManager.Core/Objects/ActionGraph.cs @@ -153,13 +153,14 @@ public bool MatchesTrigger(SubathonTrigger trigger, SubathonEvent? subathonEvent if (subathonEvent == null) return false; if (IgnoreSimulated && subathonEvent.Source == SubathonEventSource.Simulated) return false; - string eventType = $"{subathonEvent.EventType}"; + 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; + ? $"{((SubathonEventType?)type).GetSource()} {((SubathonEventType?)type).GetLabel()}" + : eventType; } private static bool IsJsonObject(string text) { @@ -298,19 +299,19 @@ public sealed class ActionGraph { public ActionRepeatMode OnRepeat { get; set; } = ActionRepeatMode.Restart; - [JsonIgnore] - public bool HasTriggers => Nodes.Any(n => n.Step.Type == ActionStepType.Trigger); - - public static bool AllowedWithTriggers(ActionRepeatMode mode) { - return mode is ActionRepeatMode.Parallel or ActionRepeatMode.Queue; - } + [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++) { @@ -407,6 +408,7 @@ public bool IsValid(out string error) { !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; } @@ -474,6 +476,7 @@ public sealed class CustomAction { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool Disabled { get; set; } + public ActionGraph Graph { get; set; } = new(); public string ToJson() { @@ -497,9 +500,15 @@ public static bool TryParse(string? json, [NotNullWhen(true)] out CustomAction? } } -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 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; @@ -508,11 +517,6 @@ public sealed class ActionRunProgress { // a step saving to %resp% also sets %resp_status% public const string StatusSuffix = "_status"; - - public void SetOutput(string name, string value, string status) { - SetVariable(name, value); - SetVariable($"{name}{StatusSuffix}", status); - } private readonly Lock _lock = new(); public HashSet Done { get; set; } = []; @@ -524,6 +528,11 @@ public void SetOutput(string name, string value, string status) { 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); From ca7bddc5752c28ecb6ffbbf3eb3b419df468e784 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:04:02 -0300 Subject: [PATCH 14/23] fix labels on http action step enums --- SubathonManager.Core/Enums/ActionEnums.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SubathonManager.Core/Enums/ActionEnums.cs b/SubathonManager.Core/Enums/ActionEnums.cs index 6da0c93..f637003 100644 --- a/SubathonManager.Core/Enums/ActionEnums.cs +++ b/SubathonManager.Core/Enums/ActionEnums.cs @@ -107,13 +107,13 @@ public enum ActionStepType { StreamerBotAction = 500, [ActionStepMeta(Label = "GET Request", Group = "Web", TargetLabel = "URL:", - DurationLabel = "Timeout (blank = 10s):", + 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 = 10s):", AllowsVariables = true, IsWebRequest = true)] + DurationLabel = "Timeout (blank = 15s):", AllowsVariables = true, IsWebRequest = true)] HttpPost = 601 } From 68a9863db0b1dc6157ba85329784e73c97f7bf12 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:41:43 -0300 Subject: [PATCH 15/23] add new known goaffpro store --- SubathonManager.Data/DbContext.cs | 41 +++---------------------------- 1 file changed, 4 insertions(+), 37 deletions(-) diff --git a/SubathonManager.Data/DbContext.cs b/SubathonManager.Data/DbContext.cs index f4b100d..6580e16 100644 --- a/SubathonManager.Data/DbContext.cs +++ b/SubathonManager.Data/DbContext.cs @@ -513,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) { @@ -541,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() @@ -586,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 From 5cf860fb18e8ade173a02a60c7545653dabd5852 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:37:03 -0300 Subject: [PATCH 16/23] add warnings for blocked makeship issues --- .../Models/MakeShipTracking.cs | 1 + .../MakeShipService.cs | 40 ++++++++++++++++--- .../MakeShipServiceTests.cs | 39 ++++++++++++++++++ .../External/MakeShipSettings.axaml.cs | 9 +++++ 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/SubathonManager.Core/Models/MakeShipTracking.cs b/SubathonManager.Core/Models/MakeShipTracking.cs index 583264b..3cd6d70 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.Integration/MakeShipService.cs b/SubathonManager.Integration/MakeShipService.cs index 4c76b53..d551dfd 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,15 @@ 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 bool _backedOff; private int _pollSweepRunning; private bool _running; @@ -61,6 +64,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 +142,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 +165,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 +234,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 +259,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 +283,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 +306,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; @@ -380,7 +398,7 @@ private void BroadcastStatus(bool force = false) { }); } - 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 +406,16 @@ 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; + 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?.LogWarning("[MakeShip] Forbidden (403) from {Url}. Headers: {Headers}. Body: {Body}", + url, headers, Truncate(body, 500)); + return null; + } + if (!response.IsSuccessStatusCode) { logger?.LogDebug("[MakeShip] {Status} from {Url}", response.StatusCode, url); return null; diff --git a/SubathonManager.Tests/IntegrationUnitTests/MakeShipServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/MakeShipServiceTests.cs index 3a7bb87..6d11711 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.UI/Views/SettingsViews/External/MakeShipSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml.cs index 7d08761..6e79bf7 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml.cs @@ -199,6 +199,15 @@ private static void RefreshRowStatus(TrackedValueRow row) { return; } + if (tracking.Blocked) { + info.StatusText.Text = "Blocked by MakeShip (403)"; + info.StatusText.Foreground = Brushes.IndianRed; + ToolTip.SetTip(info.StatusText, + "MakeShip is refusing requests from the app (403 Forbidden). Retrying in 10 minutes"); + return; + } + + ToolTip.SetTip(info.StatusText, null); switch (tracking.ProductType) { case MakeShipProductType.Petition: case MakeShipProductType.Campaign: From dc91eb861642aaab954d2aa16ac15889af9ee6ec Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:50:50 -0300 Subject: [PATCH 17/23] add makeship feature flag for warning --- SubathonManager.Core/FeatureFlags.cs | 1 + .../Views/SettingsViews/External/MakeShipSettings.axaml | 4 ++++ .../Views/SettingsViews/External/MakeShipSettings.axaml.cs | 5 +++++ 3 files changed, 10 insertions(+) diff --git a/SubathonManager.Core/FeatureFlags.cs b/SubathonManager.Core/FeatureFlags.cs index 6cd2427..55d191f 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.UI/Views/SettingsViews/External/MakeShipSettings.axaml b/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml index 0c22507..2f68d07 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml @@ -11,6 +11,10 @@ + diff --git a/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml.cs index 6e79bf7..494ac38 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml.cs @@ -44,6 +44,11 @@ public MakeShipSettings() { UpdateStatus(Utils.GetConnection(SubathonEventSource.MakeShip, nameof(SubathonEventSource.MakeShip))); }; Unloaded += (_, _) => { IntegrationEvents.ConnectionUpdated -= UpdateStatus; }; + + if (FeatureFlags.MakeShipMarkAsExperimental) { + ExperimentalFlag.IsVisible = true; + ExperimentalFlag.Foreground = Brushes.Orange; + } } public override void Init(SettingsView host) { From 1b1080d14669c92fcabc01cbd26fade6be1e491f Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:16:21 -0300 Subject: [PATCH 18/23] send multi events for each goal completed --- .../MakeShipService.cs | 15 ++++++--- SubathonManager.Services/EventService.cs | 11 +++++-- .../ServicesUnitTests/EventServiceTests.cs | 31 +++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/SubathonManager.Integration/MakeShipService.cs b/SubathonManager.Integration/MakeShipService.cs index d551dfd..7b4cbcb 100644 --- a/SubathonManager.Integration/MakeShipService.cs +++ b/SubathonManager.Integration/MakeShipService.cs @@ -408,11 +408,16 @@ private void BroadcastStatus(bool force = false) { HttpResponseMessage response = await client.GetAsync(url, ct); if (response.StatusCode == HttpStatusCode.Forbidden) { tracking.Blocked = true; - 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?.LogWarning("[MakeShip] Forbidden (403) from {Url}. Headers: {Headers}. Body: {Body}", - url, headers, Truncate(body, 500)); + if ((bool)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; } diff --git a/SubathonManager.Services/EventService.cs b/SubathonManager.Services/EventService.cs index 472c39a..0824776 100644 --- a/SubathonManager.Services/EventService.cs +++ b/SubathonManager.Services/EventService.cs @@ -582,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!); } @@ -595,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 { @@ -843,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.Tests/ServicesUnitTests/EventServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs index 4106ca1..32173ba 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.Include(s => s.Goals) + .FirstAsync(TestContext.Current.CancellationToken); + set.Goals.Add(new SubathonGoal { Text = "C", Points = 150 }); + set.Goals.Add(new SubathonGoal { Text = "A", Points = 110 }); + set.Goals.Add(new SubathonGoal { Text = "B", Points = 120 }); + set.Goals.Add(new SubathonGoal { Text = "D", Points = 200 }); + 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) = From 5f2907572f9bed7cb434d15cb78493ea36eccda0 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:53:22 -0300 Subject: [PATCH 19/23] Fix fourthwall devtunnel startup again --- SubathonManager.Integration/DevTunnelsService.cs | 5 +++-- SubathonManager.Integration/FourthWallService.cs | 9 ++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/SubathonManager.Integration/DevTunnelsService.cs b/SubathonManager.Integration/DevTunnelsService.cs index 617dae3..9f34d79 100644 --- a/SubathonManager.Integration/DevTunnelsService.cs +++ b/SubathonManager.Integration/DevTunnelsService.cs @@ -155,8 +155,9 @@ public async Task LogoutAsync(CancellationToken ct = defau } 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 1000269..323c6f6 100644 --- a/SubathonManager.Integration/FourthWallService.cs +++ b/SubathonManager.Integration/FourthWallService.cs @@ -60,7 +60,7 @@ public async Task StartAsync(CancellationToken ct = default) { 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"); @@ -153,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(); } From ad9d63b880d30f89e09f49b6be1e39c6b366c268 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:41:35 -0300 Subject: [PATCH 20/23] use utils parser for cultured decimal numbers #345 --- SubathonManager.Core/Models/Widget.cs | 5 +- .../Objects/VTSWheelAction.cs | 2 +- SubathonManager.Core/Utils.cs | 53 ++++++++------- SubathonManager.Data/WidgetEntityHelper.cs | 2 +- SubathonManager.Data/WidgetPorter.cs | 2 +- .../FourthWallService.cs | 2 +- .../MakeShipService.cs | 14 +++- SubathonManager.Integration/PallyService.cs | 2 +- .../StreamElementsService.cs | 2 +- .../StreamLabsService.cs | 2 +- SubathonManager.Integration/ThroneService.cs | 6 +- SubathonManager.Integration/TiltifyService.cs | 2 +- .../TipeeeStreamService.cs | 3 +- SubathonManager.Integration/YouTubeService.cs | 2 +- .../CoreUnitTests/UtilsTests.cs | 66 ++++++++++++++++++- .../ServicesUnitTests/EventServiceTests.cs | 14 ++-- SubathonManager.UI/Controls/SymIcon.cs | 3 +- .../EditRouteWindow.Handlers.cs | 13 ++-- SubathonManager.UI/MainWindow.Multiplier.cs | 2 +- .../MainWindow.SubathonTimer.cs | 2 +- .../Validation/NumericInputBehaviour.cs | 2 + .../Actions/ActionEditorWindow.Properties.cs | 3 +- SubathonManager.UI/Views/SettingsControl.cs | 8 +-- .../Views/SettingsView.Settings.cs | 2 +- .../Extensions/ChatExtensionSettings.axaml.cs | 16 ++--- .../StreamElementsSettings.axaml.cs | 4 +- .../Extensions/StreamLabsSettings.axaml.cs | 4 +- .../Extensions/TangiaSettings.axaml.cs | 4 +- .../Extensions/TipeeeStreamSettings.axaml.cs | 4 +- .../Extensions/TreatStreamSettings.axaml.cs | 4 +- .../External/ExternalServiceSettings.axaml.cs | 12 ++-- .../External/FourthWallSettings.axaml.cs | 18 ++--- .../GoAffPro/GoAffProSourceControl.axaml.cs | 8 +-- .../External/JuniperSettings.axaml.cs | 8 +-- .../External/KoFiCombinedSettings.axaml.cs | 20 +++--- .../External/MakeShipSettings.axaml.cs | 8 +-- .../External/PallySettings.axaml.cs | 4 +- .../External/PatreonSettings.axaml.cs | 4 +- .../External/ThroneSettings.axaml.cs | 14 ++-- .../External/TiltifySettings.axaml.cs | 4 +- .../VTubeStudioSettings.axaml.cs | 2 +- .../Streaming/PicartoSettings.axaml.cs | 12 ++-- .../Streaming/TwitchSettings.axaml.cs | 18 ++--- .../Streaming/YouTubeSettings.axaml.cs | 12 ++-- .../Views/WheelSpin/WheelEditor.Actions.cs | 9 +-- .../WheelSpin/WheelTriggerEditor.axaml.cs | 4 +- 46 files changed, 243 insertions(+), 164 deletions(-) diff --git a/SubathonManager.Core/Models/Widget.cs b/SubathonManager.Core/Models/Widget.cs index b6957e0..aa91c0a 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}"); diff --git a/SubathonManager.Core/Objects/VTSWheelAction.cs b/SubathonManager.Core/Objects/VTSWheelAction.cs index a9c6aa0..81978cc 100644 --- a/SubathonManager.Core/Objects/VTSWheelAction.cs +++ b/SubathonManager.Core/Objects/VTSWheelAction.cs @@ -219,6 +219,6 @@ static ActionOperation ToToggleOp(VtsToggleAction action) { } 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/Utils.cs b/SubathonManager.Core/Utils.cs index fb04e08..51f347c 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> @@ -146,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) { @@ -162,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; } @@ -176,28 +178,28 @@ 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"; } } @@ -240,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); @@ -391,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/WidgetEntityHelper.cs b/SubathonManager.Data/WidgetEntityHelper.cs index d1e48bd..1144528 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 af8e296..cece3b5 100644 --- a/SubathonManager.Data/WidgetPorter.cs +++ b/SubathonManager.Data/WidgetPorter.cs @@ -566,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.Integration/FourthWallService.cs b/SubathonManager.Integration/FourthWallService.cs index 323c6f6..c46bc9b 100644 --- a/SubathonManager.Integration/FourthWallService.cs +++ b/SubathonManager.Integration/FourthWallService.cs @@ -57,6 +57,7 @@ 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); @@ -64,7 +65,6 @@ public async Task StartAsync(CancellationToken ct = default) { } else { logger?.LogInformation("[FourthWall] Has not been setup before. Integration is disabled"); - BroadcastStatus(false, null); } await Task.CompletedTask; diff --git a/SubathonManager.Integration/MakeShipService.cs b/SubathonManager.Integration/MakeShipService.cs index 7b4cbcb..44b2f14 100644 --- a/SubathonManager.Integration/MakeShipService.cs +++ b/SubathonManager.Integration/MakeShipService.cs @@ -33,6 +33,7 @@ public partial class MakeShipService( 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; @@ -386,13 +387,20 @@ 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 }); diff --git a/SubathonManager.Integration/PallyService.cs b/SubathonManager.Integration/PallyService.cs index 258657a..43bbcd7 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/StreamElementsService.cs b/SubathonManager.Integration/StreamElementsService.cs index bdacc1d..eeb566d 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 e2da91f..ee5b5e5 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/ThroneService.cs b/SubathonManager.Integration/ThroneService.cs index 7b258be..002d122 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 0ce118a..ad20773 100644 --- a/SubathonManager.Integration/TiltifyService.cs +++ b/SubathonManager.Integration/TiltifyService.cs @@ -402,7 +402,7 @@ public async Task> FetchMissedEventsAsync(DateTime from, Dat } 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 692e7bd..5883ed3 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/YouTubeService.cs b/SubathonManager.Integration/YouTubeService.cs index c6bbcb3..14d4233 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.Tests/CoreUnitTests/UtilsTests.cs b/SubathonManager.Tests/CoreUnitTests/UtilsTests.cs index 46def99..ccaf06e 100644 --- a/SubathonManager.Tests/CoreUnitTests/UtilsTests.cs +++ b/SubathonManager.Tests/CoreUnitTests/UtilsTests.cs @@ -123,4 +123,68 @@ public void ParseCurrency() { Assert.Equal("INR", Utils.TryParseCurrency("₹")); Assert.Equal("CHF", Utils.TryParseCurrency("₣")); } -} \ No newline at end of file + + [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 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; + } + } +} diff --git a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs index 32173ba..70d19d3 100644 --- a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs @@ -1322,12 +1322,12 @@ 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.Include(s => s.Goals) - .FirstAsync(TestContext.Current.CancellationToken); - set.Goals.Add(new SubathonGoal { Text = "C", Points = 150 }); - set.Goals.Add(new SubathonGoal { Text = "A", Points = 110 }); - set.Goals.Add(new SubathonGoal { Text = "B", Points = 120 }); - set.Goals.Add(new SubathonGoal { Text = "D", Points = 200 }); + 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); } @@ -1462,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.UI/Controls/SymIcon.cs b/SubathonManager.UI/Controls/SymIcon.cs index b7ebbfa..afc3028 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 1343412..e25714b 100644 --- a/SubathonManager.UI/EditRouteWindow.Handlers.cs +++ b/SubathonManager.UI/EditRouteWindow.Handlers.cs @@ -389,11 +389,11 @@ private async Task SaveSelectedWidgetAsync() { _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) { @@ -1252,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/MainWindow.Multiplier.cs b/SubathonManager.UI/MainWindow.Multiplier.cs index 538bdf7..31d771a 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.SubathonTimer.cs b/SubathonManager.UI/MainWindow.SubathonTimer.cs index ce4d59c..c6ac3b2 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/Validation/NumericInputBehaviour.cs b/SubathonManager.UI/Validation/NumericInputBehaviour.cs index 13233f6..e1cc919 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/ActionEditorWindow.Properties.cs b/SubathonManager.UI/Views/Actions/ActionEditorWindow.Properties.cs index 0634ee6..4d844e8 100644 --- a/SubathonManager.UI/Views/Actions/ActionEditorWindow.Properties.cs +++ b/SubathonManager.UI/Views/Actions/ActionEditorWindow.Properties.cs @@ -268,8 +268,7 @@ private void ApplyStepFields() { } step.Value = step.Type.HasValue(step.Operation) - && double.TryParse((ValueBox.Text ?? "").Trim(), NumberStyles.Float, - CultureInfo.InvariantCulture, out double value) + && Utils.TryParseAmount(ValueBox.Text, out double value) ? value : null; TimeSpan duration = Utils.ParseDurationString((DurationBox.Text ?? "").Trim()); diff --git a/SubathonManager.UI/Views/SettingsControl.cs b/SubathonManager.UI/Views/SettingsControl.cs index 7a061cc..c39e0b3 100644 --- a/SubathonManager.UI/Views/SettingsControl.cs +++ b/SubathonManager.UI/Views/SettingsControl.cs @@ -325,13 +325,13 @@ internal bool SaveMembershipValues(AppDbContext db) { SubathonValue? defaultValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == type && sv.Meta == "DEFAULT"); - if (defaultValue != null && double.TryParse(_DefaultMembershipSecondsBox?.Text, out double defaultSeconds) && + if (defaultValue != null && Utils.TryParseAmount(_DefaultMembershipSecondsBox?.Text, out double defaultSeconds) && !defaultSeconds.Equals(defaultValue.Seconds)) { defaultValue.Seconds = defaultSeconds; hasUpdated = true; } - if (defaultValue != null && double.TryParse(_DefaultMembershipPointsBox?.Text, out double defaultPoints) && + if (defaultValue != null && Utils.TryParseAmount(_DefaultMembershipPointsBox?.Text, out double defaultPoints) && !defaultPoints.Equals(defaultValue.Points)) { defaultValue.Points = defaultPoints; hasUpdated = true; @@ -348,8 +348,8 @@ internal bool SaveMembershipValues(AppDbContext db) { foreach (DynamicSubRow subRow in _dynamicSubRows) { string meta = (subRow.NameBox.Text ?? "").Trim(); 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/SettingsView.Settings.cs b/SubathonManager.UI/Views/SettingsView.Settings.cs index 0cb4494..06d4ac5 100644 --- a/SubathonManager.UI/Views/SettingsView.Settings.cs +++ b/SubathonManager.UI/Views/SettingsView.Settings.cs @@ -126,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 6a12720..d3e0eea 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 dc77184..1046657 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 1907ade..25f5d88 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 8d31bcb..8d5a673 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 125a7c6..a0053a1 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 3b0c0e0..a0871b3 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 343aa34..ae608b9 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.cs b/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml.cs index 26cd262..051e951 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml.cs @@ -112,13 +112,13 @@ public override bool UpdateValueSettings(AppDbContext db) { var hasUpdated = false; SubathonValue? externalDonoValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.FourthWallDonation && 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; @@ -128,24 +128,24 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? orderVal = db.SubathonValues.FirstOrDefault(x => x.EventType == SubathonEventType.FourthWallOrder && x.Meta == ""); - if (orderVal != null && double.TryParse(ShopOrderBox.Text, out double osec) && !orderVal.Seconds.Equals(osec)) { + if (orderVal != null && Utils.TryParseAmount(ShopOrderBox.Text, out double osec) && !orderVal.Seconds.Equals(osec)) { orderVal.Seconds = osec; hasUpdated = true; } - if (orderVal != null && double.TryParse(ShopOrderBox2.Text, out double opts) && !orderVal.Points.Equals(opts)) { + if (orderVal != null && Utils.TryParseAmount(ShopOrderBox2.Text, out double opts) && !orderVal.Points.Equals(opts)) { orderVal.Points = opts; hasUpdated = true; } SubathonValue? value = db.SubathonValues.FirstOrDefault(x => x.EventType == SubathonEventType.FourthWallGiftOrder && x.Meta == ""); - if (value != null && double.TryParse(gShopOrderBox.Text, out double gosec) && !value.Seconds.Equals(gosec)) { + if (value != null && Utils.TryParseAmount(gShopOrderBox.Text, out double gosec) && !value.Seconds.Equals(gosec)) { value.Seconds = gosec; hasUpdated = true; } - if (value != null && double.TryParse(gShopOrderBox2.Text, out double gpts) && !value.Points.Equals(gpts)) { + if (value != null && Utils.TryParseAmount(gShopOrderBox2.Text, out double gpts) && !value.Points.Equals(gpts)) { value.Points = gpts; hasUpdated = true; } @@ -260,7 +260,7 @@ private void TestFwShopGiftOrder_Click(object? sender, RoutedEventArgs e) { if (quantity < 1) return; oEvent.Quantity = quantity; - if (!double.TryParse(gOrderTotalSimBox.Text, out double subtotal)) return; + if (!Utils.TryParseAmount(gOrderTotalSimBox.Text, out double subtotal)) return; oEvent.Amounts = new Fourthwall.Client.Generated.Models.Openapi.Model.GiftPurchaseV1.Amounts { Subtotal = new Money { Value = subtotal, Currency = gOrderCurrencyBox.Text }, Profit = new Money { Value = subtotal * 0.67, Currency = gOrderCurrencyBox.Text }, @@ -293,7 +293,7 @@ private void TestFwShopOrder_Click(object? sender, RoutedEventArgs e) { if (!int.TryParse(OrderQuantitySimBox.Text, out int quantity)) return; if (quantity < 1) return; item.Variant.Quantity = quantity; - if (!double.TryParse(OrderTotalSimBox.Text, out double subtotal)) return; + if (!Utils.TryParseAmount(OrderTotalSimBox.Text, out double subtotal)) return; item.Variant.Price = new Money { Currency = OrderCurrencyBox.Text, Value = subtotal }; item.Variant.UnitPrice = new Money { Currency = OrderCurrencyBox.Text, Value = subtotal / quantity }; item.Variant.Cost = new Money { Currency = OrderCurrencyBox.Text, Value = subtotal * 0.67 }; @@ -334,7 +334,7 @@ private void TestFwShopOrder_Click(object? sender, RoutedEventArgs e) { private void TestFwTip_Click(object? sender, RoutedEventArgs e) { var donoEvent = new DonationV1 { Amounts = new Amounts { Total = new Money() } }; - if (double.TryParse(SimulateFwTipAmountBox.Text, out double tipAmt)) + if (Utils.TryParseAmount(SimulateFwTipAmountBox.Text, out double tipAmt)) donoEvent.Amounts.Total.Value = tipAmt; else return; donoEvent.Amounts.Total.Currency = CurrencyBox.Text; diff --git a/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.axaml.cs index a1797d9..5c48ed8 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.axaml.cs @@ -76,12 +76,12 @@ public override bool UpdateValueSettings(AppDbContext db) { hasUpdated = true; } - if (double.TryParse(SecondsBox.Text, out double seconds) && !value.Seconds.Equals(seconds)) { + if (Utils.TryParseAmount(SecondsBox.Text, out double seconds) && !value.Seconds.Equals(seconds)) { value.Seconds = seconds; hasUpdated = true; } - if (double.TryParse(PointsBox.Text, out double points) && !value.Points.Equals(points)) { + if (Utils.TryParseAmount(PointsBox.Text, out double points) && !value.Points.Equals(points)) { value.Points = points; hasUpdated = true; } @@ -109,8 +109,8 @@ public bool UpdateConfigSettings(IConfig config, string configSection) { } public void SimulateOrder() { - decimal total = decimal.TryParse(TotalSimBox.Text, out decimal r) ? r : 0; - decimal commTotal = decimal.TryParse(CommSimBox.Text, out decimal r2) ? r2 : 0; + decimal total = (decimal)Utils.ParseAmount(TotalSimBox.Text); + decimal commTotal = (decimal)Utils.ParseAmount(CommSimBox.Text); int itemCount = int.TryParse(QuantitySimBox.Text, out int r3) ? r3 : 0; string currency = (CurrencyText.Text ?? "").Replace("[", "").Replace("]", ""); if (string.IsNullOrWhiteSpace(currency)) currency = "USD"; diff --git a/SubathonManager.UI/Views/SettingsViews/External/JuniperSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/JuniperSettings.axaml.cs index 02ed477..9d38216 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/JuniperSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/JuniperSettings.axaml.cs @@ -218,12 +218,12 @@ private static bool SaveValue(AppDbContext db, SubathonEventType eventType, Text hasUpdated = true; } - if (double.TryParse(secondsBox.Text, out double seconds) && !seconds.Equals(value.Seconds)) { + if (Utils.TryParseAmount(secondsBox.Text, out double seconds) && !seconds.Equals(value.Seconds)) { value.Seconds = seconds; hasUpdated = true; } - if (double.TryParse(pointsBox.Text, out double points) && !points.Equals(value.Points)) { + if (Utils.TryParseAmount(pointsBox.Text, out double points) && !points.Equals(value.Points)) { value.Points = points; hasUpdated = true; } @@ -332,8 +332,8 @@ private static bool SaveOverride(AppDbContext db, List juniperVal return existing.Count > 0; } - double seconds = double.TryParse(secondsText, out double s) ? s : 0; - double points = double.TryParse(pointsText, out double p) ? p : 0; + double seconds = Utils.TryParseAmount(secondsText, out double s) ? s : 0; + double points = Utils.TryParseAmount(pointsText, out double p) ? p : 0; SubathonValue? keep = existing.FirstOrDefault(); if (keep == null) { diff --git a/SubathonManager.UI/Views/SettingsViews/External/KoFiCombinedSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/KoFiCombinedSettings.axaml.cs index c9b12c5..4557072 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/KoFiCombinedSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/KoFiCombinedSettings.axaml.cs @@ -133,12 +133,12 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? tipValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.KoFiDonation); 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; } @@ -147,12 +147,12 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? shopValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.KoFiShopOrder); if (shopValue != null) { - if (double.TryParse(ShopOrderBox.Text, out double s) && !s.Equals(shopValue.Seconds)) { + if (Utils.TryParseAmount(ShopOrderBox.Text, out double s) && !s.Equals(shopValue.Seconds)) { shopValue.Seconds = s; hasUpdated = true; } - if (double.TryParse(ShopOrderBox2.Text, out double p) && !p.Equals(shopValue.Points)) { + if (Utils.TryParseAmount(ShopOrderBox2.Text, out double p) && !p.Equals(shopValue.Points)) { shopValue.Points = p; hasUpdated = true; } @@ -161,12 +161,12 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? commValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.KoFiCommissionOrder); if (commValue != null) { - if (double.TryParse(CommissionBox.Text, out double s) && !s.Equals(commValue.Seconds)) { + if (Utils.TryParseAmount(CommissionBox.Text, out double s) && !s.Equals(commValue.Seconds)) { commValue.Seconds = s; hasUpdated = true; } - if (double.TryParse(CommissionBox2.Text, out double p) && !p.Equals(commValue.Points)) { + if (Utils.TryParseAmount(CommissionBox2.Text, out double p) && !p.Equals(commValue.Points)) { commValue.Points = p; hasUpdated = true; } @@ -175,12 +175,12 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? defaultSub = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.KoFiSub && sv.Meta == "DEFAULT"); if (defaultSub != null) { - if (double.TryParse(KFSubDTextBox.Text, out double s) && !s.Equals(defaultSub.Seconds)) { + if (Utils.TryParseAmount(KFSubDTextBox.Text, out double s) && !s.Equals(defaultSub.Seconds)) { defaultSub.Seconds = s; hasUpdated = true; } - if (double.TryParse(KFSubDTextBox2.Text, out double p) && !p.Equals(defaultSub.Points)) { + if (Utils.TryParseAmount(KFSubDTextBox2.Text, out double p) && !p.Equals(defaultSub.Points)) { defaultSub.Points = p; hasUpdated = true; } @@ -203,8 +203,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/MakeShipSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml.cs index 494ac38..4bbed0f 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml.cs @@ -251,12 +251,12 @@ private static bool SaveValue(AppDbContext db, SubathonEventType eventType, Text hasUpdated = true; } - if (double.TryParse(secondsBox.Text, out double seconds) && !seconds.Equals(value.Seconds)) { + if (Utils.TryParseAmount(secondsBox.Text, out double seconds) && !seconds.Equals(value.Seconds)) { value.Seconds = seconds; hasUpdated = true; } - if (double.TryParse(pointsBox.Text, out double points) && !points.Equals(value.Points)) { + if (Utils.TryParseAmount(pointsBox.Text, out double points) && !points.Equals(value.Points)) { value.Points = points; hasUpdated = true; } @@ -353,8 +353,8 @@ private static bool SaveOverride(AppDbContext db, List makeShipVa return existing.Count > 0; } - double seconds = double.TryParse(secondsText, out double s) ? s : 0; - double points = double.TryParse(pointsText, out double p) ? p : 0; + double seconds = Utils.TryParseAmount(secondsText, out double s) ? s : 0; + double points = Utils.TryParseAmount(pointsText, out double p) ? p : 0; var updated = false; SubathonValue? keep = existing.FirstOrDefault(sv => sv.EventType == desiredType); diff --git a/SubathonManager.UI/Views/SettingsViews/External/PallySettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/PallySettings.axaml.cs index 5ab1338..dc35846 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/PallySettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/PallySettings.axaml.cs @@ -108,12 +108,12 @@ public override bool UpdateValueSettings(AppDbContext db) { var hasUpdated = false; SubathonValue? tipValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.PallyGGDonation && sv.Meta == ""); - if (tipValue != null && double.TryParse(TipBox.Text, out double seconds) && !seconds.Equals(tipValue.Seconds)) { + if (tipValue != null && Utils.TryParseAmount(TipBox.Text, out double seconds) && !seconds.Equals(tipValue.Seconds)) { tipValue.Seconds = seconds; hasUpdated = true; } - if (tipValue != null && double.TryParse(TipBox2.Text, out double points) && !points.Equals(tipValue.Points)) { + if (tipValue != null && Utils.TryParseAmount(TipBox2.Text, out double points) && !points.Equals(tipValue.Points)) { tipValue.Points = points; hasUpdated = true; } diff --git a/SubathonManager.UI/Views/SettingsViews/External/PatreonSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/PatreonSettings.axaml.cs index 6314c54..024196e 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/PatreonSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/PatreonSettings.axaml.cs @@ -84,13 +84,13 @@ public override bool UpdateValueSettings(AppDbContext db) { bool hasUpdated = SaveMembershipValues(db); SubathonValue? perUnit = db.SubathonValues.FirstOrDefault(v => v.EventType == SubathonEventType.PatreonPledge && v.Meta == Utils.PerUnitMeta); - if (perUnit != null && double.TryParse(PerUnitBox.Text, out double seconds) && + if (perUnit != null && Utils.TryParseAmount(PerUnitBox.Text, out double seconds) && !seconds.Equals(perUnit.Seconds)) { perUnit.Seconds = seconds; hasUpdated = true; } - if (perUnit != null && double.TryParse(PerUnitBox2.Text, out double points) && + if (perUnit != null && Utils.TryParseAmount(PerUnitBox2.Text, out double points) && !points.Equals(perUnit.Points)) { perUnit.Points = points; hasUpdated = true; diff --git a/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml.cs index 0103f28..88416f5 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml.cs @@ -67,13 +67,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? contribVal = db.SubathonValues.FirstOrDefault(x => x.EventType == SubathonEventType.ThroneGiftContribution && x.Meta == ""); - if (contribVal != null && double.TryParse(ContribBox.Text, out double osec) && + if (contribVal != null && Utils.TryParseAmount(ContribBox.Text, out double osec) && !contribVal.Seconds.Equals(osec)) { contribVal.Seconds = osec; hasUpdated = true; } - if (contribVal != null && double.TryParse(ContribBox2.Text, out double opts) && + if (contribVal != null && Utils.TryParseAmount(ContribBox2.Text, out double opts) && !contribVal.Points.Equals(opts)) { contribVal.Points = opts; hasUpdated = true; @@ -81,12 +81,12 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? value = db.SubathonValues.FirstOrDefault(x => x.EventType == SubathonEventType.ThroneGiftPurchase && x.Meta == ""); - if (value != null && double.TryParse(GiftsBox.Text, out double gosec) && !value.Seconds.Equals(gosec)) { + if (value != null && Utils.TryParseAmount(GiftsBox.Text, out double gosec) && !value.Seconds.Equals(gosec)) { value.Seconds = gosec; hasUpdated = true; } - if (value != null && double.TryParse(GiftsBox2.Text, out double gpts) && !value.Points.Equals(gpts)) { + if (value != null && Utils.TryParseAmount(GiftsBox2.Text, out double gpts) && !value.Points.Equals(gpts)) { value.Points = gpts; hasUpdated = true; } @@ -129,7 +129,7 @@ public override (string seconds, string points, TextBox? timeBox, TextBox? point } private void TestThroneGift_Click(object? sender, RoutedEventArgs e) { - double.TryParse(string.IsNullOrWhiteSpace(SimulateThroneContribAmountBox.Text) + Utils.TryParseAmount(string.IsNullOrWhiteSpace(SimulateThroneContribAmountBox.Text) ? "10.00" : SimulateThroneContribAmountBox.Text, out double amount); amount *= 100; @@ -154,7 +154,7 @@ private void TestThroneGift_Click(object? sender, RoutedEventArgs e) { } private void TestThroneContrib_Click(object? sender, RoutedEventArgs e) { - double.TryParse(string.IsNullOrWhiteSpace(SimulateThroneContribAmountBox.Text) + Utils.TryParseAmount(string.IsNullOrWhiteSpace(SimulateThroneContribAmountBox.Text) ? "10.00" : SimulateThroneContribAmountBox.Text, out double amount); amount *= 100; @@ -177,7 +177,7 @@ private void TestThroneContrib_Click(object? sender, RoutedEventArgs e) { } private void TestThroneCrowdfund_Click(object? sender, RoutedEventArgs e) { - double.TryParse(string.IsNullOrWhiteSpace(SimulateThroneContribAmountBox.Text) + Utils.TryParseAmount(string.IsNullOrWhiteSpace(SimulateThroneContribAmountBox.Text) ? "10.00" : SimulateThroneContribAmountBox.Text, out double amount); amount *= 100; diff --git a/SubathonManager.UI/Views/SettingsViews/External/TiltifySettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/TiltifySettings.axaml.cs index 25e5062..df2f9ec 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/TiltifySettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/TiltifySettings.axaml.cs @@ -114,12 +114,12 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? donoValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.TiltifyDonation && sv.Meta == ""); if (donoValue != null) { - if (double.TryParse(DonoBox.Text, out double s) && !s.Equals(donoValue.Seconds)) { + if (Utils.TryParseAmount(DonoBox.Text, out double s) && !s.Equals(donoValue.Seconds)) { donoValue.Seconds = s; hasUpdated = true; } - if (double.TryParse(DonoBox2.Text, out double p) && !p.Equals(donoValue.Points)) { + if (Utils.TryParseAmount(DonoBox2.Text, out double p) && !p.Equals(donoValue.Points)) { donoValue.Points = p; hasUpdated = true; } diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/VTubeStudioSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/VTubeStudioSettings.axaml.cs index 67c5029..52b15f3 100644 --- a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/VTubeStudioSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/VTubeStudioSettings.axaml.cs @@ -243,7 +243,7 @@ private async void SetParameter_Click(object? sender, RoutedEventArgs e) { return; } - if (!double.TryParse((VtsParameterValueBox.Text ?? "").Trim(), out double value)) { + if (!Utils.TryParseAmount((VtsParameterValueBox.Text ?? "").Trim(), out double value)) { SetHint("Enter a numeric value to set"); return; } diff --git a/SubathonManager.UI/Views/SettingsViews/Streaming/PicartoSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/Streaming/PicartoSettings.axaml.cs index 87265b0..7ef0df4 100644 --- a/SubathonManager.UI/Views/SettingsViews/Streaming/PicartoSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/Streaming/PicartoSettings.axaml.cs @@ -127,13 +127,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? cheerValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.PicartoTip && sv.Meta == ""); // divide by 100 since UI shows "per 100 kudos" - if (cheerValue != null && double.TryParse(KudosTextBox.Text, out double cheerSeconds) && + if (cheerValue != null && Utils.TryParseAmount(KudosTextBox.Text, out double cheerSeconds) && !cheerValue.Seconds.Equals(cheerSeconds / 100.0)) { cheerValue.Seconds = cheerSeconds / 100.0; hasUpdated = true; } - if (cheerValue != null && double.TryParse(Kudos2TextBox.Text, out double cheerPoints) && + if (cheerValue != null && Utils.TryParseAmount(Kudos2TextBox.Text, out double cheerPoints) && !cheerValue.Points.Equals(cheerPoints)) { cheerValue.Points = cheerPoints; hasUpdated = true; @@ -141,13 +141,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? followValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.PicartoFollow && sv.Meta == ""); - if (followValue != null && double.TryParse(FollowTextBox.Text, out double followSeconds) && + if (followValue != null && Utils.TryParseAmount(FollowTextBox.Text, out double followSeconds) && !followValue.Seconds.Equals(followSeconds)) { followValue.Seconds = followSeconds; hasUpdated = true; } - if (followValue != null && double.TryParse(Follow2TextBox.Text, out double followPoints) && + if (followValue != null && Utils.TryParseAmount(Follow2TextBox.Text, out double followPoints) && !followValue.Points.Equals(followPoints)) { followValue.Points = followPoints; hasUpdated = true; @@ -162,11 +162,11 @@ public override bool UpdateValueSettings(AppDbContext db) { private void TestPicartoTip_Click(object? sender, RoutedEventArgs e) { string? value = SimulateTipAmt.Text; - if (string.IsNullOrWhiteSpace(value)) return; + if (!Utils.TryParseAmount(value, out double amount)) return; var tip = new PicartoTip { Channel = string.IsNullOrWhiteSpace(PicartoUserBox.Text) ? "SYSTEM" : PicartoUserBox.Text, - Amount = decimal.Parse(value), + Amount = (decimal)amount, Username = "SYSTEM" }; PicartoService.ProcessAlert(tip); diff --git a/SubathonManager.UI/Views/SettingsViews/Streaming/TwitchSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/Streaming/TwitchSettings.axaml.cs index febf0a0..a4c5d78 100644 --- a/SubathonManager.UI/Views/SettingsViews/Streaming/TwitchSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/Streaming/TwitchSettings.axaml.cs @@ -73,13 +73,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? cheerValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.TwitchCheer && sv.Meta == ""); // divide by 100 since UI shows "per 100 bits" - if (cheerValue != null && double.TryParse(CheerTextBox.Text, out double cheerSeconds) && + if (cheerValue != null && Utils.TryParseAmount(CheerTextBox.Text, out double cheerSeconds) && !cheerValue.Seconds.Equals(cheerSeconds / 100.0)) { cheerValue.Seconds = cheerSeconds / 100.0; hasUpdated = true; } - if (cheerValue != null && double.TryParse(Cheer2TextBox.Text, out double cheerPoints) && + if (cheerValue != null && Utils.TryParseAmount(Cheer2TextBox.Text, out double cheerPoints) && !cheerValue.Points.Equals(cheerPoints)) { cheerValue.Points = cheerPoints; hasUpdated = true; @@ -87,13 +87,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? raidValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.TwitchRaid && sv.Meta == ""); - if (raidValue != null && double.TryParse(RaidTextBox.Text, out double raidSeconds) && + if (raidValue != null && Utils.TryParseAmount(RaidTextBox.Text, out double raidSeconds) && !raidValue.Seconds.Equals(raidSeconds)) { raidValue.Seconds = raidSeconds; hasUpdated = true; } - if (raidValue != null && double.TryParse(Raid2TextBox.Text, out double raidPoints) && + if (raidValue != null && Utils.TryParseAmount(Raid2TextBox.Text, out double raidPoints) && !raidValue.Points.Equals(raidPoints)) { raidValue.Points = raidPoints; hasUpdated = true; @@ -101,13 +101,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? followValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.TwitchFollow && sv.Meta == ""); - if (followValue != null && double.TryParse(FollowTextBox.Text, out double followSeconds) && + if (followValue != null && Utils.TryParseAmount(FollowTextBox.Text, out double followSeconds) && !followValue.Seconds.Equals(followSeconds)) { followValue.Seconds = followSeconds; hasUpdated = true; } - if (followValue != null && double.TryParse(Follow2TextBox.Text, out double followPoints) && + if (followValue != null && Utils.TryParseAmount(Follow2TextBox.Text, out double followPoints) && !followValue.Points.Equals(followPoints)) { followValue.Points = followPoints; hasUpdated = true; @@ -115,13 +115,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? tcdTipValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.TwitchCharityDonation && sv.Meta == ""); - if (tcdTipValue != null && double.TryParse(DonoBox.Text, out double tcdTipSeconds) && + if (tcdTipValue != null && Utils.TryParseAmount(DonoBox.Text, out double tcdTipSeconds) && !tcdTipValue.Seconds.Equals(tcdTipSeconds)) { tcdTipValue.Seconds = tcdTipSeconds; hasUpdated = true; } - if (tcdTipValue != null && double.TryParse(DonoBox2.Text, out double tcdTipPoints) && + if (tcdTipValue != null && Utils.TryParseAmount(DonoBox2.Text, out double tcdTipPoints) && !tcdTipValue.Points.Equals(tcdTipPoints)) { tcdTipValue.Points = tcdTipPoints; hasUpdated = true; @@ -273,7 +273,7 @@ private void LoadHypeTrainValues() { bool enabled = config.GetBool("Twitch", "HypeTrainMultiplier.Enabled"); if (HypeTrainMultBox.IsChecked != enabled) HypeTrainMultBox.IsChecked = enabled; - double.TryParse(config.Get("Twitch", "HypeTrainMultiplier.Multiplier", "1"), out double parsedAmt); + Utils.TryParseAmount(config.Get("Twitch", "HypeTrainMultiplier.Multiplier", "1"), out double parsedAmt); if (HypeTrainMultAmt.Text != parsedAmt.ToString("0.00")) HypeTrainMultAmt.Text = parsedAmt.ToString("0.00"); bool applyPts = config.GetBool("Twitch", "HypeTrainMultiplier.Points"); diff --git a/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.axaml.cs index 1fd60c0..6a7848a 100644 --- a/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.axaml.cs @@ -113,13 +113,13 @@ public override bool UpdateValueSettings(AppDbContext db) { var hasUpdated = false; SubathonValue? superchatValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.YouTubeSuperChat && sv.Meta == ""); - if (superchatValue != null && double.TryParse(DonoBox.Text, out double scSeconds) && + if (superchatValue != null && Utils.TryParseAmount(DonoBox.Text, out double scSeconds) && !scSeconds.Equals(superchatValue.Seconds)) { superchatValue.Seconds = scSeconds; hasUpdated = true; } - if (superchatValue != null && double.TryParse(DonoBox2.Text, out double scPoints) && + if (superchatValue != null && Utils.TryParseAmount(DonoBox2.Text, out double scPoints) && !scPoints.Equals(superchatValue.Points)) { superchatValue.Points = scPoints; hasUpdated = true; @@ -127,13 +127,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? redirectValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.YouTubeRedirect && sv.Meta == ""); - if (redirectValue != null && double.TryParse(RaidBox.Text, out double rdSeconds) && + if (redirectValue != null && Utils.TryParseAmount(RaidBox.Text, out double rdSeconds) && !rdSeconds.Equals(redirectValue.Seconds)) { redirectValue.Seconds = rdSeconds; hasUpdated = true; } - if (redirectValue != null && double.TryParse(RaidBox2.Text, out double rdPoints) && + if (redirectValue != null && Utils.TryParseAmount(RaidBox2.Text, out double rdPoints) && !rdPoints.Equals(redirectValue.Points)) { redirectValue.Points = rdPoints; hasUpdated = true; @@ -162,8 +162,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/WheelSpin/WheelEditor.Actions.cs b/SubathonManager.UI/Views/WheelSpin/WheelEditor.Actions.cs index 27baeb0..08f21e6 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelEditor.Actions.cs +++ b/SubathonManager.UI/Views/WheelSpin/WheelEditor.Actions.cs @@ -62,8 +62,7 @@ private void ParseMultiplierParameter(string parameter) { } private string BuildMultiplierParameter() { - if (!double.TryParse(MultiplierAmountBox.Text, NumberStyles.Float, - CultureInfo.InvariantCulture, out double amount)) + if (!Utils.TryParseAmount(MultiplierAmountBox.Text, out double amount)) amount = 1.0; TimeSpan duration = Utils.ParseDurationString((MultiplierDurationBox.Text ?? "").Trim()); string durationStr = duration == TimeSpan.Zero ? "x" : ((int)duration.TotalSeconds).ToString(); @@ -158,8 +157,7 @@ private static bool IsActionValid(WheelSpinActionType type, string param, out st return false; } - if (!double.TryParse(parts[0], NumberStyles.Float, - CultureInfo.InvariantCulture, out double amt) || amt == 0) { + if (!Utils.TryParseAmount(parts[0], out double amt) || amt == 0) { error = "Multiplier amount must be a non-zero number"; return false; } @@ -433,8 +431,7 @@ private VTSWheelAction ReadVtsBoxes() { } private static double ParseDoubleOrZero(string? text) { - return double.TryParse((text ?? "").Trim(), NumberStyles.Float, - CultureInfo.InvariantCulture, out double v) ? v : 0d; + return Utils.ParseAmount(text); } private string BuildVtsParameter() { diff --git a/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs b/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs index 2cd2cbf..5d4e29d 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs +++ b/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs @@ -788,7 +788,7 @@ private async void SaveTrigger_Click(object? sender, RoutedEventArgs e) { } else if (OrderByMoneyRadio.IsChecked == true) { if (string.IsNullOrWhiteSpace(OrderMoneyBox.Text) || - !double.TryParse(OrderMoneyBox.Text, out double om) || om <= 0) { + !Utils.TryParseAmount(OrderMoneyBox.Text, out double om) || om <= 0) { TriggerStatusText.Text = "Order amount must be a positive number"; return; } @@ -806,7 +806,7 @@ private async void SaveTrigger_Click(object? sender, RoutedEventArgs e) { case SubathonEventSubType.DonationLike: if (string.IsNullOrWhiteSpace(DonationMoneyBox.Text) || - !double.TryParse(DonationMoneyBox.Text, out double dm) || dm <= 0) { + !Utils.TryParseAmount(DonationMoneyBox.Text, out double dm) || dm <= 0) { TriggerStatusText.Text = "Donation amount must be a positive number"; return; } From 86c4974c37f0652ae96c31503bac12e9bda651bb Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:49:39 -0300 Subject: [PATCH 21/23] update some dependencies --- .github/workflows/build.yml | 2 +- SubathonManager.UI/SubathonManager.UI.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 81f0a75..039acc3 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/SubathonManager.UI/SubathonManager.UI.csproj b/SubathonManager.UI/SubathonManager.UI.csproj index def4a58..f195021 100644 --- a/SubathonManager.UI/SubathonManager.UI.csproj +++ b/SubathonManager.UI/SubathonManager.UI.csproj @@ -48,7 +48,7 @@ - + From 9c7d7ea36c303e2a4da95a1bc8db1ee0865cb69d Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:01:22 -0300 Subject: [PATCH 22/23] fix warning --- SubathonManager.Integration/MakeShipService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SubathonManager.Integration/MakeShipService.cs b/SubathonManager.Integration/MakeShipService.cs index 44b2f14..dff67dc 100644 --- a/SubathonManager.Integration/MakeShipService.cs +++ b/SubathonManager.Integration/MakeShipService.cs @@ -416,7 +416,7 @@ private void BroadcastStatus(bool force = false) { HttpResponseMessage response = await client.GetAsync(url, ct); if (response.StatusCode == HttpStatusCode.Forbidden) { tracking.Blocked = true; - if ((bool)logger?.IsEnabled(LogLevel.Debug)) { + 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); From a4b6b90be3075a3f714d69730a71319db2c84dcc Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:41:20 -0300 Subject: [PATCH 23/23] fix actions editor open location --- SubathonManager.UI/Views/Actions/ActionsView.axaml.cs | 2 ++ SubathonManager.UI/Views/Prompts/PromptsEditor.History.cs | 3 +++ SubathonManager.UI/Views/WheelSpin/WheelEditor.Actions.cs | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/SubathonManager.UI/Views/Actions/ActionsView.axaml.cs b/SubathonManager.UI/Views/Actions/ActionsView.axaml.cs index afaafb7..c61f907 100644 --- a/SubathonManager.UI/Views/Actions/ActionsView.axaml.cs +++ b/SubathonManager.UI/Views/Actions/ActionsView.axaml.cs @@ -206,6 +206,8 @@ private void EditSelected_Click(object? sender, RoutedEventArgs e) { private void OpenEditor(CustomAction action) { ActionEditorWindow editor = ActionEditorWindow.Open(action); + Window? main = TopLevel.GetTopLevel(this) as Window; + UiHelpers.CenterOver(editor, main); editor.Saved -= OnEditorSaved; editor.Saved += OnEditorSaved; } diff --git a/SubathonManager.UI/Views/Prompts/PromptsEditor.History.cs b/SubathonManager.UI/Views/Prompts/PromptsEditor.History.cs index 9d5d7cd..9de3f5c 100644 --- a/SubathonManager.UI/Views/Prompts/PromptsEditor.History.cs +++ b/SubathonManager.UI/Views/Prompts/PromptsEditor.History.cs @@ -360,6 +360,9 @@ private void PromptAction_SelectionChanged(object? sender, SelectionChangedEvent private void OpenPromptActionEditor(CustomAction action) { Guid? openedFor = _selectedPrompt?.Id; ActionEditorWindow editor = ActionEditorWindow.Open(action); + Window? main = TopLevel.GetTopLevel(this) as Window; + UiHelpers.CenterOver(editor, main); + editor.Saved += id => { if (openedFor == null || _selectedPrompt?.Id != openedFor) return; if (SelectedPromptActionId == id) { diff --git a/SubathonManager.UI/Views/WheelSpin/WheelEditor.Actions.cs b/SubathonManager.UI/Views/WheelSpin/WheelEditor.Actions.cs index 08f21e6..4b21067 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelEditor.Actions.cs +++ b/SubathonManager.UI/Views/WheelSpin/WheelEditor.Actions.cs @@ -755,6 +755,10 @@ private void CustomAction_SelectionChanged(object? sender, SelectionChangedEvent private void OpenActionEditor(CustomAction action) { Guid? openedFor = _selectedItem?.Id; ActionEditorWindow editor = ActionEditorWindow.Open(action); + + Window? main = TopLevel.GetTopLevel(this) as Window; + UiHelpers.CenterOver(editor, main); + editor.Saved += id => { if (openedFor == null || _selectedItem?.Id != openedFor || !CustomParamPanel.IsVisible) return; if (SelectedCustomActionId == id) {