diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 318edcbb..81f0a752 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -302,6 +302,14 @@ jobs: echo "file_version=0.0.0.0" >> $GITHUB_OUTPUT fi + if [[ "${GITHUB_REF}" == refs/tags/* ]] && [ "${GITHUB_REF_NAME}" != "nightly" ]; then + echo "release_tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT + echo "asset_suffix=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT + else + echo "release_tag=nightly" >> $GITHUB_OUTPUT + echo "asset_suffix=nightly" >> $GITHUB_OUTPUT + fi + - name: Inject telemetry key shell: bash run: | @@ -380,6 +388,74 @@ jobs: shell: pwsh run: Compress-Archive -Path build-avalonia/* -DestinationPath SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}.zip + - name: Build Windows installer + if: matrix.runtime == 'win-x64' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + function Find-Iscc { + $hit = @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "${env:ProgramFiles}\Inno Setup 6\ISCC.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($hit) { return $hit } + return (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source + } + + $iscc = Find-Iscc + if (-not $iscc) { + choco install innosetup --no-progress -y + $iscc = Find-Iscc + } + if (-not $iscc) { throw 'Inno Setup compiler (ISCC.exe) not found' } + Write-Host "ISCC: $iscc" + + Copy-Item LICENSE installer\windows\LICENSE.txt -Force + + $assetSuffix = "${{ steps.avars.outputs.asset_suffix }}" + $localSuffix = "${{ steps.avars.outputs.suffix }}" + $asset = "SubathonManager_${{ matrix.runtime }}_$assetSuffix.zip" + $url = "https://github.com/${{ github.repository }}/releases/download/${{ steps.avars.outputs.release_tag }}/$asset" + $appVer = $localSuffix -replace '^v', '' + + $size = (Get-ChildItem build-avalonia -Recurse -File | + Measure-Object -Property Length -Sum).Sum + Write-Host "Installer payload size: $size bytes" + + $isccArgs = @( + '/Qp' + "/DAppVer=$appVer" + "/DFileVer=${{ steps.avars.outputs.file_version }}" + "/DZipUrl=$url" + "/DZipName=$asset" + "/DZipSize=$size" + '/Oinstaller-out' + ) + + if ($assetSuffix -eq $localSuffix -and $assetSuffix -ne 'nightly') { + $sha = (Get-FileHash $asset -Algorithm SHA256).Hash + $isccArgs += "/DZipSha256=$sha" + Write-Host "Installer sha256: $sha" + } else { + Write-Host "Installer sha256: (not pinned; nightly asset rotates)" + } + Write-Host "Installer payload: $url" + + $isccArgs += 'installer\windows\SubathonManager.iss' + & $iscc @isccArgs + if ($LASTEXITCODE -ne 0) { throw "ISCC failed with exit code $LASTEXITCODE" } + + Get-ChildItem installer-out + + - name: Upload Windows installer + if: matrix.runtime == 'win-x64' + uses: actions/upload-artifact@v7 + with: + name: SubathonManager_win-x64_Setup_${{ steps.avars.outputs.suffix }} + path: installer-out/*.exe + retention-days: 3 + - name: Resolve Nextcloud upload path shell: bash run: | @@ -737,6 +813,7 @@ jobs: with: files: | dist/*.zip + dist/*.exe *.sb *.streamDeckPlugin env: @@ -768,6 +845,7 @@ jobs: prerelease: true files: | dist/*.zip + dist/*.exe *.sb *.streamDeckPlugin token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 9feeb1ff..516b7675 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ riderModule.iml exports/ imports/ cache/ +external/mixitup/ *.smo *.smw @@ -234,3 +235,7 @@ coverage-local/ # Stream Deck plugin packaging stuff *.streamDeckPlugin external/streamdeck/**/images/ + +# Windows installer build output +installer/windows/LICENSE.txt +installer-out/ diff --git a/SubathonManager.Core/AppServices.cs b/SubathonManager.Core/AppServices.cs index daa67139..7b877a5d 100644 --- a/SubathonManager.Core/AppServices.cs +++ b/SubathonManager.Core/AppServices.cs @@ -2,7 +2,7 @@ using System.Reflection; using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; -using Updatum; +using StageKit.Updatum; namespace SubathonManager.Core; @@ -99,8 +99,7 @@ public static async Task InstallUpdate(UpdatumDownloadedAsset? asset, ILog return false; try { - await AppUpdater.InstallUpdateAsync(asset); - return true; + return await AppUpdater.InstallUpdateAsync(asset); } catch (Exception ex) { logger?.LogWarning(ex, "Failed to install update"); diff --git a/SubathonManager.Core/Enums/EnumExtensions.cs b/SubathonManager.Core/Enums/EnumExtensions.cs index c4563382..6624d78e 100644 --- a/SubathonManager.Core/Enums/EnumExtensions.cs +++ b/SubathonManager.Core/Enums/EnumExtensions.cs @@ -94,6 +94,8 @@ public class EventTypeMetaAttribute : EnumMetaAttribute { public bool IsOther { get; init; } public bool HasValueConfig { get; init; } = true; + public bool HasCommissionData{ get; init; } + public SubathonEventSource Source { get; set; } = SubathonEventSource.Unknown; } diff --git a/SubathonManager.Core/Enums/MixItUpTrigger.cs b/SubathonManager.Core/Enums/MixItUpTrigger.cs new file mode 100644 index 00000000..6396bfc7 --- /dev/null +++ b/SubathonManager.Core/Enums/MixItUpTrigger.cs @@ -0,0 +1,40 @@ +namespace SubathonManager.Core.Enums; + +public enum MixItUpTrigger { + [EnumMeta(Label = "Subathon Event", Order = 0, + Description = "Any subathon event (subs, donations, orders, etc.), and commands if enabled")] + SubathonEvent, + + [EnumMeta(Label = "Timer Paused", Order = 1, Description = "The subathon timer was paused")] + TimerPaused, + + [EnumMeta(Label = "Timer Resumed", Order = 2, Description = "The subathon timer was resumed")] + TimerResumed, + + [EnumMeta(Label = "Timer Locked", Order = 3, Description = "The subathon was locked")] + TimerLocked, + + [EnumMeta(Label = "Timer Unlocked", Order = 4, Description = "The subathon was unlocked")] + TimerUnlocked, + + [EnumMeta(Label = "Multiplier Started", Order = 5, Description = "A multiplier started")] + MultiplierStarted, + + [EnumMeta(Label = "Multiplier Ended", Order = 6, Description = "A multiplier ended or was stopped")] + MultiplierEnded, + + [EnumMeta(Label = "Goal Completed", Order = 7, Description = "A goal was reached")] + GoalCompleted, + + [EnumMeta(Label = "Wheel Spin Start", Order = 8, Description = "A wheel spin started")] + WheelSpinStart, + + [EnumMeta(Label = "Wheel Spin End", Order = 9, Description = "A wheel spin landed on a result")] + WheelSpinEnd, + + [EnumMeta(Label = "Prompt Started", Order = 10, Description = "A prompt run started")] + PromptStarted, + + [EnumMeta(Label = "Prompt Ended", Order = 11, Description = "A prompt run completed, expired or was cancelled")] + PromptEnded +} diff --git a/SubathonManager.Core/Enums/ProcessSearch.cs b/SubathonManager.Core/Enums/ProcessSearch.cs index 7d3cebab..8f89c436 100644 --- a/SubathonManager.Core/Enums/ProcessSearch.cs +++ b/SubathonManager.Core/Enums/ProcessSearch.cs @@ -11,5 +11,8 @@ public enum ProcessSearch { StreamerBot, [ProcessSearchMeta(QueryNames = ["Stream Deck", "StreamDeck"])] - StreamDeck + StreamDeck, + + [ProcessSearchMeta(QueryNames = ["MixItUp", "Mix It Up"])] + MixItUp } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/ScheduleItemKind.cs b/SubathonManager.Core/Enums/ScheduleItemKind.cs new file mode 100644 index 00000000..edba1406 --- /dev/null +++ b/SubathonManager.Core/Enums/ScheduleItemKind.cs @@ -0,0 +1,6 @@ +namespace SubathonManager.Core.Enums; + +public enum ScheduleItemKind { + Event, + Task +} diff --git a/SubathonManager.Core/Enums/SubathonEventSource.cs b/SubathonManager.Core/Enums/SubathonEventSource.cs index 8de249d1..03785357 100644 --- a/SubathonManager.Core/Enums/SubathonEventSource.cs +++ b/SubathonManager.Core/Enums/SubathonEventSource.cs @@ -50,8 +50,8 @@ public enum SubathonEventSource { SourceOrder = 42, Visible = false, TrueSource = KoFi, Order = 41)] KoFiTunnel, - [EventSourceMeta(Description = "Dev Tunnels", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 904, - Visible = false, Order = 903)] + [EventSourceMeta(Description = "Dev Tunnels", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 994, + Visible = false, Order = 993)] DevTunnels, [EventSourceMeta(Description = "FourthWall", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 62, @@ -104,7 +104,11 @@ public enum SubathonEventSource { [EventSourceMeta(Description = "VTube Studio", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 905, Order = 904, Visible = false)] - VTubeStudio + VTubeStudio, + + [EventSourceMeta(Description = "Mix It Up", SourceGroup = SubathonSourceGroup.ExternalSoftware, + SourceOrder = 904, Order = 903, Visible = false, IsExternalSource = true)] + MixItUp } [ExcludeFromCodeCoverage] diff --git a/SubathonManager.Core/Enums/SubathonEventType.cs b/SubathonManager.Core/Enums/SubathonEventType.cs index e82f7f7e..a5883e5f 100644 --- a/SubathonManager.Core/Enums/SubathonEventType.cs +++ b/SubathonManager.Core/Enums/SubathonEventType.cs @@ -114,15 +114,15 @@ public enum SubathonEventType { YouTubeRedirect, [EventTypeMeta(Label = "Shop Order", Source = SubathonEventSource.KoFi, IsOrder = true, IsExternal = true, - Order = 3)] + HasCommissionData = true, Order = 3)] KoFiShopOrder, [EventTypeMeta(Label = "Commission", Source = SubathonEventSource.KoFi, IsOrder = true, IsExternal = true, - Order = 4)] + HasCommissionData = true, Order = 4)] KoFiCommissionOrder, [EventTypeMeta(Label = "Shop Order", Source = SubathonEventSource.FourthWall, IsOrder = true, IsExternal = true, - Order = 3)] + HasCommissionData = true, Order = 3)] FourthWallOrder, [EventTypeMeta(Label = "Donation", Source = SubathonEventSource.FourthWall, IsCurrencyDonation = true, @@ -135,7 +135,7 @@ public enum SubathonEventType { FourthWallMembership, [EventTypeMeta(Label = "Gift Order", Source = SubathonEventSource.FourthWall, IsOrder = true, IsExternal = true, - Order = 4)] + HasCommissionData = true, Order = 4)] FourthWallGiftOrder, [Obsolete] @@ -175,7 +175,7 @@ public enum SubathonEventType { // dynamic GoAffPro order type, meta is site id [GoAffProTypeMeta(Label = "GoAffPro Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 1, - Enabled = true)] + HasCommissionData = true, Enabled = true)] GoAffProOrder, [Obsolete] @@ -271,6 +271,10 @@ public static bool IsOrder(this SubathonEventType? value) { return value.Meta()?.IsOrder == true; } + public static bool IsOrderWitCommission(this SubathonEventType? value) { + return value.Meta()?.IsOrder == true && value.Meta()?.HasCommissionData == true; + } + public static bool IsEvent(this SubathonEventType? value) { return value.Meta()?.IsGenericEvent == true; } diff --git a/SubathonManager.Core/Events/IntegrationEvents.cs b/SubathonManager.Core/Events/IntegrationEvents.cs index 5f41bed1..a48323d1 100644 --- a/SubathonManager.Core/Events/IntegrationEvents.cs +++ b/SubathonManager.Core/Events/IntegrationEvents.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Enums; using SubathonManager.Core.Objects; namespace SubathonManager.Core.Events; @@ -8,12 +9,17 @@ public static class IntegrationEvents { public static event Action? ConnectionUpdated; // status, src, acc name, service public static event Action>? FourthWallMembershipsSynced; public static event Action? DevTunnelLegacyNotification; + public static event Action? ExternalSourceSeen; public static void RaiseConnectionUpdate(IntegrationConnection connection) { Utils.UpdateConnection(connection); ConnectionUpdated?.Invoke(connection); } + public static void RaiseExternalSourceSeen(SubathonEventSource source) { + ExternalSourceSeen?.Invoke(source); + } + public static void RaiseFourthWallMembershipsSynced(Dictionary synced) { FourthWallMembershipsSynced?.Invoke(synced); } diff --git a/SubathonManager.Core/Events/ScheduleEvents.cs b/SubathonManager.Core/Events/ScheduleEvents.cs new file mode 100644 index 00000000..ccc961c8 --- /dev/null +++ b/SubathonManager.Core/Events/ScheduleEvents.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SubathonManager.Core.Events; + +[ExcludeFromCodeCoverage] +public static class ScheduleEvents { + public static event Action? ScheduleChanged; + + public static void RaiseScheduleChanged(object? source) { + ScheduleChanged?.Invoke(source); + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Events/SubathonEvents.cs b/SubathonManager.Core/Events/SubathonEvents.cs index 66df15f8..0f37470b 100644 --- a/SubathonManager.Core/Events/SubathonEvents.cs +++ b/SubathonManager.Core/Events/SubathonEvents.cs @@ -13,6 +13,7 @@ public static event Action? SubathonEventProcessed; // Run through queue, processed or not to subathon public static event Action>? SubathonEventsDeleted; + public static event Action? SubathonEventCancelled; public static event Action? SubathonDataUpdate; public static event Action, long, GoalsType>? SubathonGoalListUpdated; @@ -77,6 +78,10 @@ public static void RaiseSubathonEventsDeleted(List subathonEvent) SubathonEventsDeleted?.Invoke(subathonEvent); } + public static void RaiseSubathonEventCancelled(Guid id, SubathonEventType type, string reference) { + SubathonEventCancelled?.Invoke(id, type, reference); + } + public static void RaiseSubathonEventCreated(SubathonEvent subathonEvent) { SubathonEventCreated?.Invoke(subathonEvent); } diff --git a/SubathonManager.Core/Models/ScheduleItem.cs b/SubathonManager.Core/Models/ScheduleItem.cs new file mode 100644 index 00000000..0146a100 --- /dev/null +++ b/SubathonManager.Core/Models/ScheduleItem.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using SubathonManager.Core.Enums; + +namespace SubathonManager.Core.Models; + +public class ScheduleItem { + [Key] public Guid Id { get; set; } = Guid.NewGuid(); + public DateTime Date { get; set; } = DateTime.Today; + + public ScheduleItemKind Kind { get; set; } = ScheduleItemKind.Event; + public string Title { get; set; } = ""; + public string Description { get; set; } = ""; + public int? StartMinute { get; set; } + public int? EndMinute { get; set; } + + public bool IsDone { get; set; } + public int SortOrder { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.Now; + + [NotMapped] public bool IsAllDay => StartMinute == null; + + public static string FormatMinute(int minute) { + minute = (minute % 1440 + 1440) % 1440; + return $"{minute / 60:00}:{minute % 60:00}"; + } + + public static bool TryParseTime(string? text, out int? minute) { + minute = null; + string raw = (text ?? "").Trim().Replace('.', ':'); + if (raw.Length == 0) return true; + + int hour, min; + if (raw.Contains(':')) { + string[] parts = raw.Split(':'); + if (parts.Length != 2 || !int.TryParse(parts[0], out hour)) return false; + if (parts[1].Length == 0) min = 0; + else if (!int.TryParse(parts[1], out min)) return false; + } + else { + if (!int.TryParse(raw, out int digits)) return false; + if (raw.Length <= 2) { + hour = digits; + min = 0; + } + else if (raw.Length <= 4) { + hour = digits / 100; + min = digits % 100; + } + else { + return false; + } + } + + if (hour is < 0 or > 23 || min is < 0 or > 59) return false; + minute = hour * 60 + min; + return true; + } + + public string TimeLabel() { + if (StartMinute == null) return "All day"; + string start = FormatMinute(StartMinute.Value); + if (EndMinute == null) return start; + string end = FormatMinute(EndMinute.Value); + return EndMinute < StartMinute ? $"{start} - {end} (+1)" : $"{start} - {end}"; + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/StateValue.cs b/SubathonManager.Core/Models/StateValue.cs index ed7c0971..e7fe3512 100644 --- a/SubathonManager.Core/Models/StateValue.cs +++ b/SubathonManager.Core/Models/StateValue.cs @@ -19,4 +19,9 @@ public static class StateKeys { public const string EditorPreviewLightBg = "EditorPreviewLightBg"; public const string GoalAutoIncrement = "GoalAutoIncrement"; + + public const string ScheduleSkipDeleteConfirm = "ScheduleSkipDeleteConfirm"; + + // bigint bitmask of enums + public const string RecentEventsHiddenTypes = "RecentEventsHiddenTypes"; } \ No newline at end of file diff --git a/SubathonManager.Core/ScheduleCsv.cs b/SubathonManager.Core/ScheduleCsv.cs new file mode 100644 index 00000000..f7dd546e --- /dev/null +++ b/SubathonManager.Core/ScheduleCsv.cs @@ -0,0 +1,223 @@ +using System.Globalization; +using System.Text; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + +namespace SubathonManager.Core; + +public static class ScheduleCsv { + private const string DateFormat = "yyyy-MM-dd"; + + public static readonly string[] Columns = [ + nameof(ScheduleItem.Date), + nameof(ScheduleItem.Kind), + nameof(ScheduleItem.StartMinute), + nameof(ScheduleItem.EndMinute), + nameof(ScheduleItem.Title), + nameof(ScheduleItem.Description), + nameof(ScheduleItem.IsDone) + ]; + + public static string Write(IEnumerable items) { + var sb = new StringBuilder(); + sb.AppendLine(string.Join(',', Columns)); + foreach (ScheduleItem item in items) + sb.AppendLine(string.Join(',', + item.Date.ToString(DateFormat, CultureInfo.InvariantCulture), + item.Kind.ToString(), + item.StartMinute is { } s ? ScheduleItem.FormatMinute(s) : "", + item.EndMinute is { } e ? ScheduleItem.FormatMinute(e) : "", + Utils.EscapeCsv(item.Title), + Utils.EscapeCsv(item.Description), + item.IsDone ? "true" : "false")); + return sb.ToString(); + } + + public static ParseResult Parse(string csv) { + var items = new List(); + var errors = new List(); + + List<(int line, List fields)> records = ReadRecords(csv); + if (records.Count == 0) { + errors.Add("File is empty."); + return new ParseResult(items, errors); + } + + Dictionary index = records[0].fields + .Select((name, i) => (name: name.Trim(), i)) + .GroupBy(x => x.name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().i, StringComparer.OrdinalIgnoreCase); + + foreach (string required in new[] { nameof(ScheduleItem.Date), nameof(ScheduleItem.Title) }) + if (!index.ContainsKey(required)) + errors.Add($"Missing required column \"{required}\"."); + if (errors.Count > 0) return new ParseResult(items, errors); + + string Field(List fields, string column) { + return index.TryGetValue(column, out int i) && i < fields.Count ? fields[i] : ""; + } + + foreach ((int line, List fields) in records.Skip(1)) { + if (fields.All(string.IsNullOrWhiteSpace)) continue; + + string dateRaw = Field(fields, nameof(ScheduleItem.Date)).Trim(); + if (!DateTime.TryParseExact(dateRaw, DateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, + out DateTime date) && + !DateTime.TryParse(dateRaw, CultureInfo.InvariantCulture, DateTimeStyles.None, out date)) { + errors.Add($"Line {line}: invalid date \"{dateRaw}\"."); + continue; + } + + string title = Field(fields, nameof(ScheduleItem.Title)).Trim(); + if (title.Length == 0) { + errors.Add($"Line {line}: missing title."); + continue; + } + + string kindRaw = Field(fields, nameof(ScheduleItem.Kind)).Trim(); + var kind = ScheduleItemKind.Event; + if (kindRaw.Length > 0 && (!Enum.TryParse(kindRaw, true, out kind) || !Enum.IsDefined(kind))) { + errors.Add($"Line {line}: unknown kind \"{kindRaw}\"."); + continue; + } + + string startRaw = Field(fields, nameof(ScheduleItem.StartMinute)); + if (!ScheduleItem.TryParseTime(startRaw, out int? start)) { + errors.Add($"Line {line}: invalid start time \"{startRaw.Trim()}\"."); + continue; + } + + string endRaw = Field(fields, nameof(ScheduleItem.EndMinute)); + if (!ScheduleItem.TryParseTime(endRaw, out int? end)) { + errors.Add($"Line {line}: invalid end time \"{endRaw.Trim()}\"."); + continue; + } + + if (start == null || end == start) end = null; + + string doneRaw = Field(fields, nameof(ScheduleItem.IsDone)).Trim(); + bool done = doneRaw.Equals("true", StringComparison.OrdinalIgnoreCase) || doneRaw is "1" || + doneRaw.Equals("yes", StringComparison.OrdinalIgnoreCase); + + items.Add(new ScheduleItem { + Date = date.Date, + Kind = kind, + StartMinute = start, + EndMinute = end, + Title = title, + Description = Field(fields, nameof(ScheduleItem.Description)).TrimEnd(), + IsDone = done + }); + } + + return new ParseResult(items, errors); + } + + public static ImportPlan PlanImport(IEnumerable existing, IEnumerable incoming) { + List known = existing.ToList(); + var toAdd = new List(); + var toUpdate = new List(); + var unchanged = 0; + + Dictionary nextOrder = known + .GroupBy(i => i.Date.Date) + .ToDictionary(g => g.Key, g => g.Max(i => i.SortOrder) + 1); + + foreach (ScheduleItem item in incoming) { + ScheduleItem? match = known.FirstOrDefault(k => IsSameSlot(k, item)); + if (match != null) { + if (match.Description == item.Description) { + unchanged++; + continue; + } + + match.Description = item.Description; + if (!toAdd.Contains(match) && !toUpdate.Contains(match)) toUpdate.Add(match); + continue; + } + + DateTime day = item.Date.Date; + int order = nextOrder.GetValueOrDefault(day, 0); + nextOrder[day] = order + 1; + item.SortOrder = order; + toAdd.Add(item); + known.Add(item); + } + + return new ImportPlan(toAdd, toUpdate, unchanged); + } + + private static bool IsSameSlot(ScheduleItem a, ScheduleItem b) { + return a.Date.Date == b.Date.Date && a.Title == b.Title && + a.StartMinute == b.StartMinute && a.EndMinute == b.EndMinute; + } + + private static List<(int line, List fields)> ReadRecords(string csv) { + var records = new List<(int, List)>(); + var fields = new List(); + var field = new StringBuilder(); + var inQuotes = false; + var line = 1; + var recordStart = 1; + var any = false; + + if (csv.Length > 0 && csv[0] == (char)0xFEFF) csv = csv[1..]; + + for (var i = 0; i < csv.Length; i++) { + char c = csv[i]; + if (inQuotes) { + if (c == '"' && i + 1 < csv.Length && csv[i + 1] == '"') { + field.Append('"'); + i++; + } + else if (c == '"') { + inQuotes = false; + } + else { + if (c == '\n') line++; + if (c != '\r') field.Append(c); + } + + continue; + } + + switch (c) { + case '"': + inQuotes = true; + any = true; + break; + case ',': + fields.Add(field.ToString()); + field.Clear(); + any = true; + break; + case '\r': + break; + case '\n': + fields.Add(field.ToString()); + field.Clear(); + if (any || fields.Count > 1 || fields[0].Length > 0) records.Add((recordStart, fields)); + fields = []; + any = false; + line++; + recordStart = line; + break; + default: + field.Append(c); + any = true; + break; + } + } + + if (any || field.Length > 0) { + fields.Add(field.ToString()); + records.Add((recordStart, fields)); + } + + return records; + } + + public sealed record ParseResult(List Items, List Errors); + + public sealed record ImportPlan(List ToAdd, List ToUpdate, int Unchanged); +} \ No newline at end of file diff --git a/SubathonManager.Core/SubathonManager.Core.csproj b/SubathonManager.Core/SubathonManager.Core.csproj index 71505809..fcdc7fc4 100644 --- a/SubathonManager.Core/SubathonManager.Core.csproj +++ b/SubathonManager.Core/SubathonManager.Core.csproj @@ -13,7 +13,7 @@ - + diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index dc3034b7..545ce2ca 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -139,6 +139,20 @@ 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); + } + + public static double ParseAmount(string? text) { + return TryParseAmount(text, out double value) ? value : 0; + } + public static string TryParseCurrency(string amountString) { var currency = ""; Match match = Regex.Match(amountString, @"^(?[A-Z]{3})(?![A-Z])"); @@ -273,7 +287,7 @@ public static bool IsCommissionAsDonation(IConfig config, SubathonEvent ev) { return config.GetBool( ev.EventType.GetSource().ToString(), $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", - ev.EventType.GetSource() != SubathonEventSource.GoAffPro); + ev.EventType.GetSource() != SubathonEventSource.GoAffPro && ev.EventType.IsOrderWitCommission()); } public sealed class ServiceReconnectState : IDisposable { diff --git a/SubathonManager.Data/DbContext.cs b/SubathonManager.Data/DbContext.cs index 1452b2e0..4407a6e0 100644 --- a/SubathonManager.Data/DbContext.cs +++ b/SubathonManager.Data/DbContext.cs @@ -50,6 +50,8 @@ public AppDbContext(DbContextOptions options) public DbSet StateValues { get; set; } + public DbSet ScheduleItems { get; set; } + public DbSet WidgetCatalogEntries => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -98,6 +100,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() .HasKey(sv => new { sv.EventType, sv.Meta }); + modelBuilder.Entity() + .HasIndex(i => i.Date); + modelBuilder.Entity() .HasMany(s => s.Goals) .WithOne(g => g.LinkedGoalSet) @@ -261,7 +266,10 @@ public static async Task> GetSubathonCurrencyEvents(AppDbCon var orderTypesToInclude = new List(); foreach (SubathonEventType orderEvent in Enum.GetValues().Where(et => ((SubathonEventType?)et).IsOrder() && !et.IsDisabled() - && et.GetSource() != SubathonEventSource.GoAffPro)) { + && et.GetSource() != SubathonEventSource.GoAffPro + && et.GetSource() != SubathonEventSource.MakeShip + && et.GetSource() != SubathonEventSource.JuniperCreates + && et.GetSource() != SubathonEventSource.TreatStream)) { bool asDonation = Utils.DonationSettings.TryGetValue($"{orderEvent.ToString()?.Split("Order")[0]}", out bool donation) && donation; @@ -521,7 +529,7 @@ 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" } }; foreach (GoAffProStore def in defaults) { diff --git a/SubathonManager.Data/Migrations/20260923161414_addScheduleSetup.Designer.cs b/SubathonManager.Data/Migrations/20260923161414_addScheduleSetup.Designer.cs new file mode 100644 index 00000000..0f4e06e1 --- /dev/null +++ b/SubathonManager.Data/Migrations/20260923161414_addScheduleSetup.Designer.cs @@ -0,0 +1,1140 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SubathonManager.Data; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260923161414_addScheduleSetup")] + partial class addScheduleSetup + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("CssVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.GoAffProStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("GoAffProStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("JsVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.Property("Valid") + .HasColumnType("INTEGER"); + + b.HasKey("ProductId"); + + b.HasIndex("StoreId"); + + b.ToTable("JuniperProducts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.ToTable("JuniperStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MakeShipTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Orders") + .HasColumnType("INTEGER"); + + b.Property("ProductType") + .HasColumnType("INTEGER"); + + b.Property("Sales") + .HasColumnType("INTEGER"); + + b.Property("ShopifyProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MakeShipTrackings"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ApplyToPoints") + .HasColumnType("INTEGER"); + + b.Property("ApplyToSeconds") + .HasColumnType("INTEGER"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("FromHypeTrain") + .HasColumnType("INTEGER"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Started") + .HasColumnType("TEXT"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SubathonId") + .IsUnique(); + + b.ToTable("MultiplierDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.ScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EndMinute") + .HasColumnType("INTEGER"); + + b.Property("IsDone") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("StartMinute") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("ScheduleItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.StateValue", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("StateValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CapDateTime") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsPaused") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsCumulative") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsElapsed") + .HasColumnType("INTEGER"); + + b.Property("MoneySum") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("ReversedTime") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Source") + .HasColumnType("INTEGER") + .HasColumnOrder(1); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("Command") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("CurrentPoints") + .HasColumnType("INTEGER"); + + b.Property("CurrentTime") + .HasColumnType("INTEGER"); + + b.Property("EventTimestamp") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("EventTypeMeta") + .HasColumnType("TEXT"); + + b.Property("MultiplierPoints") + .HasColumnType("REAL"); + + b.Property("MultiplierSeconds") + .HasColumnType("REAL"); + + b.Property("PointsValue") + .HasColumnType("INTEGER"); + + b.Property("ProcessedToSubathon") + .HasColumnType("INTEGER"); + + b.Property("SecondaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecondsValue") + .HasColumnType("REAL"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.Property("TertiaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("User") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasReversed") + .HasColumnType("INTEGER"); + + b.HasKey("Id", "Source"); + + b.HasIndex("SubathonId"); + + b.ToTable("SubathonEvents"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("GoalSetId") + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GoalSetId"); + + b.ToTable("SubathonGoals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonGoalSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CompletionDuration") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FilterEventType") + .HasColumnType("INTEGER"); + + b.Property("FilterMeta") + .HasColumnType("TEXT"); + + b.Property("FilterSubType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SubType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPrompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BaselineCount") + .HasColumnType("INTEGER"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("PromptId") + .HasColumnType("TEXT"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SnapshotTargetValue") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PromptId"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPromptRuns"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Cooldown") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RandomOffset") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonPromptSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonValue", b => + { + b.Property("EventType") + .HasColumnType("INTEGER") + .HasColumnOrder(0); + + b.Property("Meta") + .HasColumnType("TEXT") + .HasColumnOrder(1); + + b.Property("Points") + .HasColumnType("REAL"); + + b.Property("Seconds") + .HasColumnType("REAL"); + + b.HasKey("EventType", "Meta"); + + b.ToTable("SubathonValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Weight") + .HasColumnType("INTEGER"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.ToTable("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SpinCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("WheelSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ActionType") + .HasColumnType("INTEGER"); + + b.Property("Parameter") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelItemId") + .IsUnique(); + + b.ToTable("WheelSpinActions"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.HasIndex("WheelItemId"); + + b.ToTable("WheelSpinHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CountThreshold") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MoneyThreshold") + .HasColumnType("REAL"); + + b.Property("SpinsToAdd") + .HasColumnType("INTEGER"); + + b.Property("TierValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WheelSpinTriggers"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("SpinsAdded") + .HasColumnType("INTEGER"); + + b.Property("SubathonEventId") + .HasColumnType("TEXT"); + + b.Property("SubathonEventType") + .HasColumnType("INTEGER"); + + b.Property("TriggerId") + .HasColumnType("TEXT"); + + b.Property("TriggerSource") + .HasColumnType("INTEGER"); + + b.Property("TriggerUser") + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TriggerId"); + + b.ToTable("WheelSpinTriggerHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("HtmlPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RouteId") + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RouteId"); + + b.ToTable("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WidgetCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Entry") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileModifiedTicks") + .HasColumnType("INTEGER"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Group") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewCachePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewImage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PackPath") + .IsUnique(); + + b.ToTable("WidgetCatalogEntries"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("CssVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("JsVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.HasOne("SubathonManager.Core.Models.JuniperStore", "Store") + .WithMany("Products") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithOne("Multiplier") + .HasForeignKey("SubathonManager.Core.Models.MultiplierData", "SubathonId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithMany() + .HasForeignKey("SubathonId"); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonGoalSet", "LinkedGoalSet") + .WithMany("Goals") + .HasForeignKey("GoalSetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedGoalSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany("Prompts") + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPrompt", "LinkedPrompt") + .WithMany() + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany() + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("LinkedPrompt"); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany("WheelItems") + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithOne("Action") + .HasForeignKey("SubathonManager.Core.Models.WheelSpinAction", "WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany() + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithMany() + .HasForeignKey("WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSpinTrigger", "Trigger") + .WithMany("History") + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.HasOne("SubathonManager.Core.Models.Route", "Route") + .WithMany("Widgets") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Navigation("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Navigation("Multiplier") + .IsRequired(); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Navigation("Goals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Navigation("Prompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Navigation("Action"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Navigation("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Navigation("CssVariables"); + + b.Navigation("JsVariables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SubathonManager.Data/Migrations/20260923161414_addScheduleSetup.cs b/SubathonManager.Data/Migrations/20260923161414_addScheduleSetup.cs new file mode 100644 index 00000000..2707a80d --- /dev/null +++ b/SubathonManager.Data/Migrations/20260923161414_addScheduleSetup.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + /// + public partial class addScheduleSetup : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ScheduleItems", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + Kind = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", nullable: false), + StartMinute = table.Column(type: "INTEGER", nullable: true), + EndMinute = table.Column(type: "INTEGER", nullable: true), + IsDone = table.Column(type: "INTEGER", nullable: false), + SortOrder = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduleItems", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleItems_Date", + table: "ScheduleItems", + column: "Date"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ScheduleItems"); + } + } +} diff --git a/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs b/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs index 1e76a4ea..638541d5 100644 --- a/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class AppDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => { @@ -248,6 +248,48 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Routes"); }); + modelBuilder.Entity("SubathonManager.Core.Models.ScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EndMinute") + .HasColumnType("INTEGER"); + + b.Property("IsDone") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("StartMinute") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("ScheduleItems"); + }); + modelBuilder.Entity("SubathonManager.Core.Models.StateValue", b => { b.Property("Name") diff --git a/SubathonManager.Data/StateValueHelper.cs b/SubathonManager.Data/StateValueHelper.cs index 0cde6433..9d7c49c2 100644 --- a/SubathonManager.Data/StateValueHelper.cs +++ b/SubathonManager.Data/StateValueHelper.cs @@ -51,6 +51,15 @@ public static async Task SetAsync(IDbContextFactory factory, st await SetAsync(db, name, value); } + public static async Task AddIntAsync(IDbContextFactory factory, string name, int delta) { + await using AppDbContext db = await factory.CreateDbContextAsync(); + await db.Database.ExecuteSqlInterpolatedAsync( + $"INSERT OR IGNORE INTO StateValues (Name, Value, TypeName) VALUES ({name}, '0', 'Int32')"); + await db.Database.ExecuteSqlInterpolatedAsync( + $"UPDATE StateValues SET Value = CAST(MAX(0, CAST(Value AS INTEGER) + {delta}) AS TEXT), TypeName = 'Int32' WHERE Name = {name}"); + return Get(db, name, 0); + } + public static void Set(AppDbContext db, string name, T value) where T : notnull { string strVal = value.ToString() ?? ""; string typeName = typeof(T).Name; diff --git a/SubathonManager.Integration/ExternalEventService.cs b/SubathonManager.Integration/ExternalEventService.cs index 4c0c03ee..1facb94e 100644 --- a/SubathonManager.Integration/ExternalEventService.cs +++ b/SubathonManager.Integration/ExternalEventService.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Globalization; +using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using SubathonManager.Core; using SubathonManager.Core.Enums; @@ -12,6 +13,14 @@ namespace SubathonManager.Integration; public static class ExternalEventService { + public static void NotifySourceSeen(Dictionary data) { + if (data.TryGetValue("source", out JsonElement elemSrc) && elemSrc.ValueKind == JsonValueKind.String + && Enum.TryParse(elemSrc.GetString(), true, + out SubathonEventSource source) + && source.IsExternalSource()) + IntegrationEvents.RaiseExternalSourceSeen(source); + } + public static bool ProcessExternalCommand(Dictionary data) { data.TryGetValue("command", out JsonElement elemCmd); if (elemCmd.ValueKind == JsonValueKind.String && Enum.TryParse @@ -143,9 +152,11 @@ public static bool ProcessExternalOrder(Dictionary data) { } } - if (!double.TryParse(elemValue.GetString()!, out double value)) return false; + if (!Utils.TryParseAmount(elemValue.GetString(), out double value)) return false; - var orderVal = $"{value}"; + string orderVal = value.ToString(CultureInfo.InvariantCulture); + + string moneyCurrency = currency; if (type != SubathonEventType.KoFiCommissionOrder) { var section = $"{type.GetSource()}"; var modeKey = $"{type}"; @@ -183,7 +194,7 @@ public static bool ProcessExternalOrder(Dictionary data) { EventType = type, EventTypeMeta = goAffProMeta, Amount = amt, - SecondaryValue = $"{value}|{currency}" + SecondaryValue = $"{value.ToString(CultureInfo.InvariantCulture)}|{moneyCurrency}" }; data.TryGetValue("id", out JsonElement elemId); @@ -219,12 +230,12 @@ public static bool ProcessExternalDonation(Dictionary data) data.TryGetValue("amount", out JsonElement elemValue); if (elemValue.ValueKind != JsonValueKind.String) return false; - if (!double.TryParse(elemValue.GetString()!, out double value)) return false; + if (!Utils.TryParseAmount(elemValue.GetString(), out double value)) return false; var subathonEvent = new SubathonEvent { Currency = currency, User = user, - Value = $"{value}", + Value = value.ToString(CultureInfo.InvariantCulture), Source = user == "SYSTEM" ? SubathonEventSource.Simulated : ((SubathonEventType?)type).GetSource(), EventType = type }; diff --git a/SubathonManager.Integration/FourthWallService.cs b/SubathonManager.Integration/FourthWallService.cs index 4527b63f..14366af2 100644 --- a/SubathonManager.Integration/FourthWallService.cs +++ b/SubathonManager.Integration/FourthWallService.cs @@ -43,6 +43,7 @@ public class FourthWallService( private readonly string _configSection = "FourthWall"; private readonly FourthwallWebhookHandler _handler = new(new FourthwallWebhookSignatureVerifier()); + private readonly SemaphoreSlim _initLock = new(1, 1); internal readonly string _oAuthURl = "https://oauth.subathonmanager.app/auth/fourthwall/login"; internal readonly string _refreshURl = "https://oauth.subathonmanager.app/auth/fourthwall/refresh"; @@ -55,6 +56,7 @@ public class FourthWallService( private string? RefreshToken => secureStorage.GetOrDefault(StorageKeys.FourthWallRefreshToken, string.Empty); private string? ShopName { get; set; } public string WebhookPath => "/api/webhooks/fourthwall"; + public const string AutoDeleteCancelledKey = "AutoDeleteCancelledOrders"; public async Task StartAsync(CancellationToken ct = default) { IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; @@ -113,6 +115,11 @@ public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary CheckForTokenAsync(CancellationToken ct = default) { [ExcludeFromCodeCoverage] public async Task Initialize(CancellationToken ct = default) { + if (!await _initLock.WaitAsync(0, ct)) return; + try { + await InitializeCoreAsync(ct); + } + finally { + _initLock.Release(); + } + } + + [ExcludeFromCodeCoverage] + private async Task InitializeCoreAsync(CancellationToken ct) { IntegrationConnection tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); - if (!tunnelConn.Status) return; + if (!tunnelConn.Status) { + await devTunnels.StartTunnelAsync(ct); + tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); + if (!tunnelConn.Status) { + string reason = !devTunnels.IsCliInstalled ? "the DevTunnels CLI isn't installed" + : !devTunnels.IsLoggedIn ? "DevTunnels isn't logged in" + : "the tunnel failed to start"; + logger?.LogWarning("[FourthWall] Can't connect: {Reason}", reason); + ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventSource.FourthWall), + $"FourthWall needs a DevTunnel but {reason}. Check the DevTunnels settings.", DateTime.Now); + BroadcastStatus(HasTokenFile(), null); + return; + } + } bool canConnect = await CheckForTokenAsync(ct); if (!canConnect || string.IsNullOrWhiteSpace(AccessToken)) { @@ -187,9 +234,29 @@ public async Task Initialize(CancellationToken ct = default) { if (!string.IsNullOrWhiteSpace(webhookConfigurationV1.Url) && webhookConfigurationV1.Url.Contains(tunnelConn.Name.Replace("https://", ""), StringComparison.CurrentCultureIgnoreCase)) { - // TODO what if we add more future scopes, we'd need to compare allowed_types. hasWh = true; logger?.LogDebug("Webhook found, no need to make a new one for fourthwall"); + if (webhookConfigurationV1.AllowedTypes?.Contains(WebhookConfigurationV1_allowedTypes.ORDER_UPDATED) != true && + !string.IsNullOrWhiteSpace(webhookConfigurationV1.Id)) + try { + List types = + (webhookConfigurationV1.AllowedTypes ?? []) + .Select(t => Enum.TryParse($"{t}", out WebhookConfigurationUpdateRequest_allowedTypes u) + ? u + : (WebhookConfigurationUpdateRequest_allowedTypes?)null) + .Where(t => t != null) + .Append(WebhookConfigurationUpdateRequest_allowedTypes.ORDER_UPDATED) + .ToList(); + await client.OpenApi.V10.Webhooks[webhookConfigurationV1.Id].PutAsync( + new WebhookConfigurationUpdateRequest { + Url = webhookConfigurationV1.Url, AllowedTypes = types + }, cancellationToken: ct); + logger?.LogInformation("[FourthWall] Added order updates to existing webhook"); + } + catch (Exception ex) { + logger?.LogWarning(ex, "[FourthWall] Couldn't add order updates to existing webhook"); + } + break; } @@ -201,6 +268,7 @@ public async Task Initialize(CancellationToken ct = default) { Url = fullUrl, AllowedTypes = [ WebhookConfigurationCreateRequest_allowedTypes.ORDER_PLACED, + WebhookConfigurationCreateRequest_allowedTypes.ORDER_UPDATED, WebhookConfigurationCreateRequest_allowedTypes.DONATION, WebhookConfigurationCreateRequest_allowedTypes.SUBSCRIPTION_PURCHASED, WebhookConfigurationCreateRequest_allowedTypes.SUBSCRIPTION_CHANGED, @@ -280,9 +348,10 @@ private async Task StartOAuthFlowAsync() { logger?.LogDebug("Opening FourthWall OAuth..."); OpenBrowser(_oAuthURl); (string? newAccess, string? newRefresh) = await WaitForProtocolCallbackAsync(); - if (!string.IsNullOrEmpty(AccessToken) || string.IsNullOrEmpty(RefreshToken)) { - secureStorage.Set(StorageKeys.FourthWallAccessToken, newAccess!); - secureStorage.Set(StorageKeys.FourthWallRefreshToken, newRefresh!); + + if (!string.IsNullOrEmpty(newAccess) && !string.IsNullOrEmpty(newRefresh)) { + secureStorage.Set(StorageKeys.FourthWallAccessToken, newAccess); + secureStorage.Set(StorageKeys.FourthWallRefreshToken, newRefresh); } } @@ -390,7 +459,9 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { var itemCount = 0; double totalValue = 0; double totalDirect = 0; - string currency = order.Amounts?.Subtotal?.Currency ?? defaultCurrency; + string currency = !string.IsNullOrWhiteSpace(order.Amounts?.Subtotal?.Currency) + ? order.Amounts.Subtotal.Currency + : defaultCurrency; double costs = 0; double prices = 0; @@ -427,7 +498,7 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { Currency = sourceMode switch { OrderTypeModes.Item => "items", OrderTypeModes.Order => "order", - _ => string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency + _ => !string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency }, Amount = Math.Max(itemCount, 1), SecondaryValue = $"{totalDirect.ToString("F2", CultureInfo.InvariantCulture)}|{ @@ -444,7 +515,9 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { double totalValue = 0; double totalDirect = 0; - string currency = order.Amounts?.Subtotal?.Currency ?? defaultCurrency; + string currency = !string.IsNullOrWhiteSpace(order.Amounts?.Subtotal?.Currency) + ? order.Amounts.Subtotal.Currency + : defaultCurrency; totalValue += order.Amounts?.Subtotal?.Value ?? 0; totalDirect += order.Amounts?.Profit?.Value ?? 0; @@ -464,7 +537,7 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { Currency = sourceMode2 switch { OrderTypeModes.Item => "items", OrderTypeModes.Order => "order", - _ => string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency + _ => !string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency }, Amount = Math.Max(itemCount, 1), SecondaryValue = $"{totalDirect.ToString("F2", CultureInfo.InvariantCulture)}|{ diff --git a/SubathonManager.Integration/KoFiService.cs b/SubathonManager.Integration/KoFiService.cs index 8dc3fe87..3c9a5c84 100644 --- a/SubathonManager.Integration/KoFiService.cs +++ b/SubathonManager.Integration/KoFiService.cs @@ -158,7 +158,7 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { EventType = SubathonEventType.KoFiDonation, User = username, Value = d.Amount.ToString("F2", CultureInfo.InvariantCulture), - Currency = string.IsNullOrWhiteSpace(d.Currency) ? d.Currency : defaultCurrency, + Currency = !string.IsNullOrWhiteSpace(d.Currency) ? d.Currency : defaultCurrency, EventTimestamp = d.Timestamp.LocalDateTime }, KoFiSubscriptionStartedEvent s => new SubathonEvent { @@ -194,11 +194,11 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { Currency = sourceMode switch { OrderTypeModes.Item => "items", OrderTypeModes.Order => "order", - _ => string.IsNullOrWhiteSpace(shop.Currency) ? shop.Currency : defaultCurrency + _ => !string.IsNullOrWhiteSpace(shop.Currency) ? shop.Currency : defaultCurrency }, Amount = shop.ShopItems?.Count ?? 1, SecondaryValue = $"{shop.Amount.ToString("F2", CultureInfo.InvariantCulture)}|{ - (string.IsNullOrWhiteSpace(shop.Currency) ? shop.Currency : defaultCurrency)}", + (!string.IsNullOrWhiteSpace(shop.Currency) ? shop.Currency : defaultCurrency)}", EventTimestamp = shop.Timestamp.LocalDateTime }, KoFiCommissionEvent comm => new SubathonEvent { @@ -207,7 +207,9 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { EventType = SubathonEventType.KoFiCommissionOrder, User = username, Value = comm.Amount.ToString("F2", CultureInfo.InvariantCulture), - Currency = string.IsNullOrWhiteSpace(comm.Currency) ? comm.Currency : defaultCurrency, + Currency = !string.IsNullOrWhiteSpace(comm.Currency) ? comm.Currency : defaultCurrency, + SecondaryValue = $"{comm.Amount.ToString("F2", CultureInfo.InvariantCulture)}|{ + (!string.IsNullOrWhiteSpace(comm.Currency) ? comm.Currency : defaultCurrency)}", Amount = 1, EventTimestamp = comm.Timestamp.LocalDateTime }, diff --git a/SubathonManager.Integration/MixItUpService.cs b/SubathonManager.Integration/MixItUpService.cs new file mode 100644 index 00000000..2e29d818 --- /dev/null +++ b/SubathonManager.Integration/MixItUpService.cs @@ -0,0 +1,622 @@ +using System.Globalization; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; + +namespace SubathonManager.Integration; + +public sealed record MixItUpCommandInfo(Guid Id, string Name, string Type, string GroupName, bool IsEnabled) { + public string DisplayName => string.IsNullOrWhiteSpace(GroupName) ? Name : $"{GroupName} / {Name}"; +} + +public class MixItUpService( + ILogger? logger, + IConfig config, + IHttpClientFactory httpClientFactory, + ITimerService timerService) : IAppService, IDisposable { + + public const string ConfigSection = "MixItUp"; + public const string DefaultApiUrl = "http://localhost:8911/api/v2"; + public const string ServiceName = "MixItUp"; + public const string IdentifierPrefix = "subathonmanager"; + public const string IncludeCommandsKey = "SubathonEvent.IncludeCommands"; + + internal static readonly TimeSpan SeenWindow = TimeSpan.FromMinutes(90); + internal static readonly TimeSpan ProbeInterval = TimeSpan.FromMinutes(10); + private const int CommandPageSize = 100; + private const int MaxCommandPages = 50; + private const string SeenTimerKey = "mixitup-seen"; + private const string ProbeTimerKey = "mixitup-probe"; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions RequestJsonOptions = new(); + + private readonly Lock _lock = new(); + + private bool _lastLocked; + private MultiplierSnapshot? _lastMultiplier; + private bool _lastPaused; + private Guid? _trackedSubathonId; + + public static string CommandsFolder => Path.GetFullPath(Path.Combine("external", "mixitup")); + + public DateTime? LastSeen { get; private set; } + public string? Version { get; private set; } + public bool Connected => LastSeen != null; + + public bool Enabled => config.GetBool(ConfigSection, "Enabled"); + public bool IncludeCommands => config.GetBool(ConfigSection, IncludeCommandsKey, false); + + public string ApiUrl { + get { + string url = (config.Get(ConfigSection, "ApiUrl", DefaultApiUrl) ?? "").Trim().TrimEnd('/'); + return string.IsNullOrWhiteSpace(url) ? DefaultApiUrl : url; + } + } + + public static string CommandConfigKey(MixItUpTrigger trigger) { + return $"Command.{trigger}"; + } + + public string GetCommandId(MixItUpTrigger trigger) { + return (config.Get(ConfigSection, CommandConfigKey(trigger), "") ?? "").Trim(); + } + + public Task StartAsync(CancellationToken ct = default) { + Unsubscribe(); + Subscribe(); + BroadcastStatus(); + timerService.Register(ProbeTimerKey, ProbeInterval, () => _ = ProbeAsync(ct)); + _ = Task.Run(() => ProbeAsync(ct), ct); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct = default) { + Unsubscribe(); + timerService.Unregister(ProbeTimerKey); + timerService.Unregister(SeenTimerKey); + lock (_lock) { + LastSeen = null; + _trackedSubathonId = null; + _lastMultiplier = null; + } + + BroadcastStatus(); + return Task.CompletedTask; + } + + public void Dispose() { + Unsubscribe(); + timerService.Unregister(ProbeTimerKey); + timerService.Unregister(SeenTimerKey); + GC.SuppressFinalize(this); + } + + private void Subscribe() { + IntegrationEvents.ExternalSourceSeen += OnExternalSourceSeen; + SubathonEvents.SubathonEventProcessed += OnSubathonEventProcessed; + SubathonEvents.SubathonDataUpdate += OnSubathonDataUpdate; + SubathonEvents.SubathonGoalCompleted += OnGoalCompleted; + SubathonEvents.PromptRunStarted += OnPromptRunStarted; + SubathonEvents.PromptRunUpdate += OnPromptRunUpdate; + WheelEvents.WheelSpinStarted += OnWheelSpinStarted; + WheelEvents.WheelSpinResult += OnWheelSpinResult; + } + + private void Unsubscribe() { + IntegrationEvents.ExternalSourceSeen -= OnExternalSourceSeen; + SubathonEvents.SubathonEventProcessed -= OnSubathonEventProcessed; + SubathonEvents.SubathonDataUpdate -= OnSubathonDataUpdate; + SubathonEvents.SubathonGoalCompleted -= OnGoalCompleted; + SubathonEvents.PromptRunStarted -= OnPromptRunStarted; + SubathonEvents.PromptRunUpdate -= OnPromptRunUpdate; + WheelEvents.WheelSpinStarted -= OnWheelSpinStarted; + WheelEvents.WheelSpinResult -= OnWheelSpinResult; + } + + public async Task ProbeAsync(CancellationToken ct = default) { + try { + using HttpClient client = CreateClient(); + using HttpResponseMessage response = await client.GetAsync($"{ApiUrl}/status/version", ct); + if (!response.IsSuccessStatusCode) return false; + + string body = (await response.Content.ReadAsStringAsync(ct)).Trim(); + MarkSeen(DateTime.Now, ParseVersion(body)); + return true; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { + throw; + } + catch (Exception ex) { + logger?.LogDebug("[MixItUp] Probe failed: {Message}", ex.Message); + return false; + } + } + + internal static string? ParseVersion(string body) { + if (string.IsNullOrWhiteSpace(body)) return null; + try { + if (body.StartsWith('"')) return JsonSerializer.Deserialize(body); + } + catch (JsonException) { + /**/ + } + + return body; + } + + private void OnExternalSourceSeen(SubathonEventSource source) { + if (source == SubathonEventSource.MixItUp) MarkSeen(DateTime.Now); + } + + internal void MarkSeen(DateTime now, string? version = null) { + bool changed; + lock (_lock) { + changed = LastSeen == null; + LastSeen = now; + if (!string.IsNullOrWhiteSpace(version) && version != Version) { + Version = version; + changed = true; + } + } + + timerService.Register(SeenTimerKey, SeenWindow, Expire); + if (changed) BroadcastStatus(); + } + + internal void Expire() { + timerService.Unregister(SeenTimerKey); + lock (_lock) { + if (LastSeen == null) return; + LastSeen = null; + } + + BroadcastStatus(); + } + + private void BroadcastStatus() { + bool seen = Connected; + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Source = SubathonEventSource.MixItUp, + Service = ServiceName, + Name = seen && !string.IsNullOrWhiteSpace(Version) ? $"v{Version}" : "", + Status = seen, + Configured = seen + }); + } + + private HttpClient CreateClient() { + HttpClient client = httpClientFactory.CreateClient(nameof(MixItUpService)); + client.Timeout = TimeSpan.FromSeconds(5); + return client; + } + + public async Task RunCommandAsync(Guid commandId, IReadOnlyDictionary identifiers, + CancellationToken ct = default) { + try { + var payload = new { + Arguments = "", + SpecialIdentifiers = identifiers, + IgnoreRequirements = false + }; + using HttpClient client = CreateClient(); + using HttpResponseMessage response = + await client.PostAsJsonAsync($"{ApiUrl}/commands/{commandId}", payload, RequestJsonOptions, ct); + if (response.IsSuccessStatusCode) { + MarkSeen(DateTime.Now); + return true; + } + + string detail = await response.Content.ReadAsStringAsync(ct); + logger?.LogWarning("[MixItUp] Running command {CommandId} failed with {Status}: {Detail}", commandId, + (int)response.StatusCode, detail); + return false; + } + catch (Exception ex) { + logger?.LogDebug("[MixItUp] Running command {CommandId} failed: {Message}", commandId, ex.Message); + return false; + } + } + + public async Task?> GetCommandsAsync(CancellationToken ct = default) { + var result = new List(); + try { + using HttpClient client = CreateClient(); + for (var page = 0; page < MaxCommandPages; page++) { + string url = $"{ApiUrl}/commands?skip={page * CommandPageSize}&pageSize={CommandPageSize}"; + using HttpResponseMessage response = await client.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) return null; + + CommandListResponse? list = await response.Content.ReadFromJsonAsync( + JsonOptions, ct); + if (list?.Commands == null || list.Commands.Count == 0) break; + + result.AddRange(list.Commands.Select(c => new MixItUpCommandInfo(c.Id, c.Name ?? "", + c.Type ?? "", c.GroupName ?? "", c.IsEnabled))); + if (result.Count >= list.TotalCount) break; + } + + result = await KeepRunnableAsync(client, result, ct); + MarkSeen(DateTime.Now); + } + catch (Exception ex) { + logger?.LogDebug("[MixItUp] Fetching commands failed: {Message}", ex.Message); + return null; + } + + return result.OrderBy(c => c.GroupName, StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private async Task> KeepRunnableAsync(HttpClient client, + List commands, CancellationToken ct) { + using var gate = new SemaphoreSlim(8); + bool[] runnable = await Task.WhenAll(commands.Select(async c => { + await gate.WaitAsync(ct); + try { + using HttpResponseMessage response = await client.GetAsync($"{ApiUrl}/commands/{c.Id}", ct); + return response.IsSuccessStatusCode; + } + catch (HttpRequestException) { + return false; + } + finally { + gate.Release(); + } + })); + return commands.Where((_, i) => runnable[i]).ToList(); + } + + private sealed class CommandListResponse { + public int TotalCount { get; set; } + public List? Commands { get; set; } + } + + private sealed class CommandEntry { + [System.Text.Json.Serialization.JsonPropertyName("ID")] + public Guid Id { get; set; } + + public string? Name { get; set; } + public string? Type { get; set; } + public string? GroupName { get; set; } + public bool IsEnabled { get; set; } + } + + internal bool Fire(MixItUpTrigger trigger, Dictionary identifiers) { + if (!Enabled) return false; + if (!Guid.TryParse(GetCommandId(trigger), out Guid commandId)) return false; + + identifiers[$"{IdentifierPrefix}trigger"] = trigger.ToString(); + _ = Task.Run(() => RunCommandAsync(commandId, identifiers)); + return true; + } + + public Task TestTriggerAsync(MixItUpTrigger trigger, string commandIdText, CancellationToken ct = default) { + if (!Guid.TryParse(commandIdText.Trim(), out Guid commandId)) return Task.FromResult(false); + Dictionary identifiers = SampleIdentifiers(trigger); + identifiers[$"{IdentifierPrefix}trigger"] = trigger.ToString(); + return RunCommandAsync(commandId, identifiers, ct); + } + + private void OnSubathonEventProcessed(SubathonEvent subathonEvent, bool effective) { + if (!config.GetBool("App", "ShowLockedEvents", false) && !subathonEvent.ProcessedToSubathon) return; + if (subathonEvent.EventType == SubathonEventType.Command && !IncludeCommands) return; + Fire(MixItUpTrigger.SubathonEvent, EventIdentifiers(subathonEvent)); + } + + private void OnSubathonDataUpdate(SubathonData subathon, DateTime timestamp) { + // check for multiplier and pause/lock info + var fired = new List<(MixItUpTrigger, Dictionary)>(3); + MultiplierSnapshot? multiplier = subathon.Multiplier?.SubathonId == subathon.Id + ? MultiplierSnapshot.From(subathon.Multiplier) + : null; + lock (_lock) { + if (_trackedSubathonId != subathon.Id) { + _trackedSubathonId = subathon.Id; + _lastPaused = subathon.IsPaused; + _lastLocked = subathon.IsLocked; + _lastMultiplier = multiplier; + return; + } + + if (subathon.IsPaused != _lastPaused) { + _lastPaused = subathon.IsPaused; + fired.Add((subathon.IsPaused ? MixItUpTrigger.TimerPaused : MixItUpTrigger.TimerResumed, + TimerIdentifiers(subathon))); + } + + if (subathon.IsLocked != _lastLocked) { + _lastLocked = subathon.IsLocked; + fired.Add((subathon.IsLocked ? MixItUpTrigger.TimerLocked : MixItUpTrigger.TimerUnlocked, + TimerIdentifiers(subathon))); + } + + if (multiplier != null) { + MultiplierSnapshot? previous = _lastMultiplier; + _lastMultiplier = multiplier; + if (previous != null) { + if (multiplier.Running && multiplier != previous) + fired.Add((MixItUpTrigger.MultiplierStarted, MultiplierIdentifiers(multiplier))); + else if (!multiplier.Running && previous.Running) + fired.Add((MixItUpTrigger.MultiplierEnded, MultiplierIdentifiers(previous))); + } + } + } + + foreach ((MixItUpTrigger trigger, Dictionary identifiers) in fired) + Fire(trigger, identifiers); + } + + private void OnGoalCompleted(SubathonGoal goal, long currentValue) { + Fire(MixItUpTrigger.GoalCompleted, new Dictionary { + [$"{IdentifierPrefix}goaltext"] = goal.Text, + [$"{IdentifierPrefix}goaltarget"] = ToValueStr(goal.Points), + [$"{IdentifierPrefix}goalcurrent"] = ToValueStr(currentValue) + }); + } + + private void OnWheelSpinStarted(WheelSet wheel, int delaySeconds) { + Fire(MixItUpTrigger.WheelSpinStart, new Dictionary { + [$"{IdentifierPrefix}wheelname"] = wheel.Name, + [$"{IdentifierPrefix}wheelid"] = wheel.Id.ToString(), + [$"{IdentifierPrefix}spindelay"] = ToValueStr(delaySeconds) + }); + } + + private void OnWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int spinsOwed) { + Fire(MixItUpTrigger.WheelSpinEnd, new Dictionary { + [$"{IdentifierPrefix}wheelname"] = wheel.Name, + [$"{IdentifierPrefix}wheelid"] = wheel.Id.ToString(), + [$"{IdentifierPrefix}wheelitem"] = item?.Text ?? "", + [$"{IdentifierPrefix}spinstatus"] = history.Status.ToString(), + [$"{IdentifierPrefix}spinsowed"] = ToValueStr(spinsOwed) + }); + } + + private void OnPromptRunStarted(SubathonPromptRun run, SubathonPrompt? prompt) { + Fire(MixItUpTrigger.PromptStarted, PromptIdentifiers(run, prompt)); + } + + private void OnPromptRunUpdate(SubathonPromptRun run, SubathonPrompt? prompt) { + if (run.IsActive) return; + Fire(MixItUpTrigger.PromptEnded, PromptIdentifiers(run, prompt)); + } + + private static string ToValueStr(IFormattable value) { + return value.ToString(null, CultureInfo.InvariantCulture); + } + + internal static Dictionary EventIdentifiers(SubathonEvent subathonEvent) { + var eventType = $"{subathonEvent.EventType}"; + string? trueSource = subathonEvent.EventType.GetTypeTrueSource(subathonEvent.EventTypeMeta); + if (subathonEvent.EventType == SubathonEventType.GoAffProOrder + && GoAffProOrderHelper.TryGetStore(subathonEvent.EventTypeMeta, out GoAffProStore? store)) { + trueSource = store.InternalName; + eventType = store.InternalEventName; + } + + double seconds = subathonEvent.GetFinalSecondsValueRaw() < 0.5 ? 0 : subathonEvent.GetFinalSecondsValue(); + return new Dictionary { + [$"{IdentifierPrefix}eventtype"] = eventType, + [$"{IdentifierPrefix}source"] = $"{subathonEvent.Source}", + [$"{IdentifierPrefix}truesource"] = trueSource ?? "", + [$"{IdentifierPrefix}subtype"] = $"{subathonEvent.EventType.GetSubType()}", + [$"{IdentifierPrefix}user"] = subathonEvent.User ?? "", + [$"{IdentifierPrefix}value"] = subathonEvent.Value, + [$"{IdentifierPrefix}amount"] = ToValueStr(subathonEvent.Amount), + [$"{IdentifierPrefix}currency"] = subathonEvent.Currency ?? "", + [$"{IdentifierPrefix}command"] = $"{subathonEvent.Command}", + [$"{IdentifierPrefix}secondsadded"] = ToValueStr(seconds), + [$"{IdentifierPrefix}pointsadded"] = ToValueStr(subathonEvent.GetFinalPointsValue()), + [$"{IdentifierPrefix}secondaryvalue"] = subathonEvent.SecondaryValue, + [$"{IdentifierPrefix}tertiaryvalue"] = subathonEvent.TertiaryValue, + [$"{IdentifierPrefix}reversed"] = $"{subathonEvent.WasReversed}" + }; + } + + internal static Dictionary TimerIdentifiers(SubathonData subathon) { + TimeSpan remaining = subathon.TimeRemainingRounded(); + return new Dictionary { + [$"{IdentifierPrefix}timeremaining"] = + $"{(int)remaining.TotalHours:00}:{remaining.Minutes:00}:{remaining.Seconds:00}", + [$"{IdentifierPrefix}secondsremaining"] = ToValueStr((long)remaining.TotalSeconds), + [$"{IdentifierPrefix}points"] = ToValueStr(subathon.Points), + [$"{IdentifierPrefix}paused"] = $"{subathon.IsPaused}", + [$"{IdentifierPrefix}locked"] = $"{subathon.IsLocked}" + }; + } + + internal sealed record MultiplierSnapshot( + bool Running, + double Multiplier, + bool Time, + bool Points, + TimeSpan? Duration, + DateTime? Started, + bool FromHypeTrain) { + public static MultiplierSnapshot From(MultiplierData data) { + return new MultiplierSnapshot(data.IsRunning(), data.Multiplier, data.ApplyToSeconds, + data.ApplyToPoints, data.Duration, data.Started, data.FromHypeTrain); + } + } + + internal static Dictionary MultiplierIdentifiers(MultiplierSnapshot multiplier) { + return new Dictionary { + [$"{IdentifierPrefix}multiplier"] = ToValueStr(multiplier.Multiplier), + [$"{IdentifierPrefix}multipliertime"] = $"{multiplier.Time}", + [$"{IdentifierPrefix}multiplierpoints"] = $"{multiplier.Points}", + [$"{IdentifierPrefix}multiplierduration"] = ToValueStr((long)(multiplier.Duration?.TotalSeconds ?? 0)), + [$"{IdentifierPrefix}multiplierhypetrain"] = $"{multiplier.FromHypeTrain}" + }; + } + + internal static Dictionary PromptIdentifiers(SubathonPromptRun run, SubathonPrompt? prompt) { + prompt ??= run.LinkedPrompt; + return new Dictionary { + [$"{IdentifierPrefix}prompttext"] = prompt?.Text ?? "", + [$"{IdentifierPrefix}prompttype"] = $"{prompt?.Type}", + [$"{IdentifierPrefix}prompttarget"] = ToValueStr(prompt?.Value ?? run.SnapshotTargetValue), + [$"{IdentifierPrefix}promptduration"] = + ToValueStr((long)(prompt?.CompletionDuration.TotalSeconds ?? (run.ExpiresAt - run.StartedAt).TotalSeconds)), + [$"{IdentifierPrefix}promptstatus"] = $"{run.Status}" + }; + } + + public static IReadOnlyList IdentifierNames(MixItUpTrigger trigger) { + return SampleIdentifiers(trigger).Keys.Prepend($"{IdentifierPrefix}trigger").Select(k => $"${k}").ToList(); + } + + internal static Dictionary SampleIdentifiers(MixItUpTrigger trigger) { + switch (trigger) { + case MixItUpTrigger.SubathonEvent: + return EventIdentifiers(new SubathonEvent { + Source = SubathonEventSource.Simulated, + EventType = SubathonEventType.ExternalDonation, + User = "TestUser", + Value = "5", + Currency = "USD", + SecondsValue = 600, + PointsValue = 5, + ProcessedToSubathon = true + }); + case MixItUpTrigger.MultiplierStarted: + case MixItUpTrigger.MultiplierEnded: + return MultiplierIdentifiers(new MultiplierSnapshot(trigger == MixItUpTrigger.MultiplierStarted, + 2, true, true, TimeSpan.FromMinutes(10), DateTime.Now, false)); + case MixItUpTrigger.TimerPaused: + case MixItUpTrigger.TimerResumed: + case MixItUpTrigger.TimerLocked: + case MixItUpTrigger.TimerUnlocked: + return new Dictionary { + [$"{IdentifierPrefix}timeremaining"] = "12:34:56", + [$"{IdentifierPrefix}secondsremaining"] = "45296", + [$"{IdentifierPrefix}points"] = "250", + [$"{IdentifierPrefix}paused"] = (trigger == MixItUpTrigger.TimerPaused).ToString(), + [$"{IdentifierPrefix}locked"] = (trigger == MixItUpTrigger.TimerLocked).ToString() + }; + case MixItUpTrigger.GoalCompleted: + return new Dictionary { + [$"{IdentifierPrefix}goaltext"] = "Test Goal", + [$"{IdentifierPrefix}goaltarget"] = "100", + [$"{IdentifierPrefix}goalcurrent"] = "100" + }; + case MixItUpTrigger.WheelSpinStart: + return new Dictionary { + [$"{IdentifierPrefix}wheelname"] = "Test Wheel", + [$"{IdentifierPrefix}wheelid"] = Guid.Empty.ToString(), + [$"{IdentifierPrefix}spindelay"] = "0" + }; + case MixItUpTrigger.WheelSpinEnd: + return new Dictionary { + [$"{IdentifierPrefix}wheelname"] = "Test Wheel", + [$"{IdentifierPrefix}wheelid"] = Guid.Empty.ToString(), + [$"{IdentifierPrefix}wheelitem"] = "Test Item", + [$"{IdentifierPrefix}spinstatus"] = "Pending", + [$"{IdentifierPrefix}spinsowed"] = "0" + }; + case MixItUpTrigger.PromptStarted: + case MixItUpTrigger.PromptEnded: + return new Dictionary { + [$"{IdentifierPrefix}prompttext"] = "Test Prompt", + [$"{IdentifierPrefix}prompttype"] = $"{SubathonPromptType.Points}", + [$"{IdentifierPrefix}prompttarget"] = "10", + [$"{IdentifierPrefix}promptduration"] = "300", + [$"{IdentifierPrefix}promptstatus"] = trigger == MixItUpTrigger.PromptStarted + ? $"{SubathonPromptRunStatus.Active}" + : $"{SubathonPromptRunStatus.Completed}" + }; + default: + return new Dictionary(); + } + } +} + +public static class MixItUpCommandExporter { + public const string FileExtension = ".miucommand"; + public const string GroupName = "Subathon Manager"; + + private const string ActionGroupCommandType = "MixItUp.Base.Model.Commands.ActionGroupCommandModel, MixItUp.Base"; + private const string RequirementsSetType = "MixItUp.Base.Model.Requirements.RequirementsSetModel, MixItUp.Base"; + private const string WebRequestActionType = "MixItUp.Base.Model.Actions.WebRequestActionModel, MixItUp.Base"; + + private const int CommandTypeActionGroup = 4; + private const int ActionTypeWebRequest = 11; + private const int ResponseTypePlainText = 0; + private const int HttpMethodPost = 1; + + public static IEnumerable ExportableCommands => + Enum.GetValues() + .Where(c => c is not (SubathonCommandType.None or SubathonCommandType.Unknown)); + + public static string CommandName(SubathonCommandType command) { + return $"Subathon - {command.GetDescription()}"; + } + + public static string BuildRequestBody(SubathonCommandType command) { + return JsonSerializer.Serialize(new Dictionary { + ["type"] = $"{SubathonEventType.Command}", + ["command"] = $"{command}", + ["message"] = command.IsParametersRequired() ? "$allargs" : "", + ["user"] = "$username", + ["source"] = $"{SubathonEventSource.MixItUp}" + }); + } + + public static string BuildCommandJson(SubathonCommandType command, int port) { + var webRequest = new JsonObject { + ["$type"] = WebRequestActionType, + ["Url"] = $"http://localhost:{port}/api/data/control", + ["ResponseType"] = ResponseTypePlainText, + ["JSONToSpecialIdentifiers"] = new JsonObject(), + ["HttpMethod"] = HttpMethodPost, + ["CustomHeaders"] = new JsonObject(), + ["RequestBody"] = BuildRequestBody(command), + ["ID"] = Guid.NewGuid().ToString(), + ["Name"] = "Web Request", + ["Type"] = ActionTypeWebRequest, + ["Enabled"] = true + }; + + var root = new JsonObject { + ["$type"] = ActionGroupCommandType, + ["RunOneRandomly"] = false, + ["ID"] = Guid.NewGuid().ToString(), + ["Name"] = CommandName(command), + ["Type"] = CommandTypeActionGroup, + ["IsEnabled"] = true, + ["Unlocked"] = false, + ["IsEmbedded"] = false, + ["GroupName"] = GroupName, + ["Triggers"] = new JsonArray(), + ["Requirements"] = new JsonObject { + ["$type"] = RequirementsSetType, + ["Requirements"] = new JsonArray() + }, + ["Actions"] = new JsonArray(webRequest) + }; + + return root.ToJsonString(); + } + + public static IReadOnlyList WriteAll(string folder, int port) { + Directory.CreateDirectory(folder); + foreach (string stale in Directory.GetFiles(folder, $"*{FileExtension}")) + File.Delete(stale); + + var written = new List(); + foreach (SubathonCommandType command in ExportableCommands) { + string path = Path.Combine(folder, $"{CommandName(command)}{FileExtension}"); + File.WriteAllText(path, BuildCommandJson(command, port)); + written.Add(path); + } + + return written; + } +} diff --git a/SubathonManager.Integration/StreamElementsService.cs b/SubathonManager.Integration/StreamElementsService.cs index 47e4b63a..bdacc1dc 100644 --- a/SubathonManager.Integration/StreamElementsService.cs +++ b/SubathonManager.Integration/StreamElementsService.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; using StreamElements.WebSocket; using StreamElements.WebSocket.Models.Internal; @@ -194,7 +195,7 @@ private void _OnTip(object? sender, Tip e) { SubathonEvent subathonEvent = new() { User = e.Username, Currency = e.Currency, - Value = $"{e.Amount}", + Value = string.Create(CultureInfo.InvariantCulture, $"{e.Amount}"), Source = SubathonEventSource.StreamElements, EventType = SubathonEventType.StreamElementsDonation }; diff --git a/SubathonManager.Integration/StreamLabsService.cs b/SubathonManager.Integration/StreamLabsService.cs index 13841b9e..e2da91f7 100644 --- a/SubathonManager.Integration/StreamLabsService.cs +++ b/SubathonManager.Integration/StreamLabsService.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using System.Globalization; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Streamlabs.SocketClient; @@ -97,7 +98,7 @@ private void OnDonation(object? o, DonationMessage message) { SubathonEvent subathonEvent = new() { User = message.Name, Currency = $"{message.Currency}".ToUpper(), - Value = $"{message.Amount}", + Value = string.Create(CultureInfo.InvariantCulture, $"{message.Amount}"), Source = SubathonEventSource.StreamLabs, EventType = SubathonEventType.StreamLabsDonation }; diff --git a/SubathonManager.Integration/TangiaService.cs b/SubathonManager.Integration/TangiaService.cs index 06915ce9..da95dfdf 100644 --- a/SubathonManager.Integration/TangiaService.cs +++ b/SubathonManager.Integration/TangiaService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Diagnostics.CodeAnalysis; using System.Net; using System.Text.Json; @@ -139,7 +140,7 @@ internal async Task PollOnceAsync(string key, CancellationToken ct) { var sev = new SubathonEvent { User = ev.Data?.OverlayParams?.BuyerInfo?.Name ?? ev.Data?.OverlayParams?.Name ?? "Tangia User", Id = Utils.CreateGuidFromUniqueString(ev.EventId), - Value = $"{ev.Data?.OverlayParams?.TriggerData?.Price}", + Value = string.Create(CultureInfo.InvariantCulture, $"{ev.Data?.OverlayParams?.TriggerData?.Price}"), Currency = "tokens", Source = SubathonEventSource.Tangia, EventType = SubathonEventType.TangiaTokens diff --git a/SubathonManager.Integration/ThroneService.cs b/SubathonManager.Integration/ThroneService.cs index 716fd507..7b258bee 100644 --- a/SubathonManager.Integration/ThroneService.cs +++ b/SubathonManager.Integration/ThroneService.cs @@ -142,7 +142,9 @@ public void ProcessData(string stringData, bool isSim = false) { throneEvent.TryGetValue("event_id", out object? uuid); data.TryGetValue("item_name", out object? itemName); data.TryGetValue("gifter_username", out object? gifterName); - data.TryGetValue("currency", out object? currency); + data.TryGetValue("currency", out object? currencyObj); + string? currencyRaw = currencyObj?.ToString(); + string currency = string.IsNullOrWhiteSpace(currencyRaw) ? "USD" : currencyRaw.Trim().ToUpperInvariant(); // data.TryGetValue("creator_username", out var creatorUsername); // bool.TryParse(isSurpriseRaw?.ToString(), out var isSurprise); // var username = creatorUsername?.ToString(); @@ -169,8 +171,8 @@ public void ProcessData(string stringData, bool isSim = false) { double.TryParse(price?.ToString() ?? "0.00", out double priceInt); subathonEvent.EventType = SubathonEventType.ThroneGiftPurchase; subathonEvent.Currency = - mode == OrderTypeModes.Dollar && !string.IsNullOrWhiteSpace(currency!.ToString()) - ? currency.ToString() + mode == OrderTypeModes.Dollar + ? currency : "item"; subathonEvent.Amount = 1; subathonEvent.Value = mode != OrderTypeModes.Dollar @@ -179,7 +181,7 @@ public void ProcessData(string stringData, bool isSim = false) { break; case "contribution_purchased": subathonEvent.EventType = SubathonEventType.ThroneGiftContribution; - subathonEvent.Currency = currency?.ToString() ?? ""; + subathonEvent.Currency = currency; data.TryGetValue("amount", out object? amount); subathonEvent.TertiaryValue = itemName?.ToString() ?? "New Contribution"; double.TryParse(amount?.ToString(), out double amountInt); @@ -191,7 +193,7 @@ public void ProcessData(string stringData, bool isSim = false) { double.TryParse(price2?.ToString() ?? "0.00", out double priceInt2); subathonEvent.EventType = SubathonEventType.ThroneCrowdGiftComplete; subathonEvent.User = itemName?.ToString() ?? "Crowdfunding Complete!"; - subathonEvent.Currency = currency?.ToString() ?? "item"; + subathonEvent.Currency = currency; subathonEvent.Value = (priceInt2 / 100).ToString("F2", CultureInfo.InvariantCulture); diff --git a/SubathonManager.Integration/TwitchService.cs b/SubathonManager.Integration/TwitchService.cs index 3bef2bde..29f7237e 100644 --- a/SubathonManager.Integration/TwitchService.cs +++ b/SubathonManager.Integration/TwitchService.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using System.Globalization; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; using SubathonManager.Core; @@ -841,7 +842,7 @@ private Task HandleCharityEvent(object? s, ChannelCharityCampaignDonateArgs e) { e.Payload.Event.Amount.Value / (decimal)Math.Pow(10, e.Payload.Event.Amount.DecimalPlaces), 2 - ).ToString("0.00"), + ).ToString("0.00", CultureInfo.InvariantCulture), Currency = e.Payload.Event.Amount.Currency, EventTimestamp = eventMeta.MessageTimestamp.ToLocalTime() }; diff --git a/SubathonManager.Integration/YouTubeService.cs b/SubathonManager.Integration/YouTubeService.cs index 4caf0569..c6bbcb3a 100644 --- a/SubathonManager.Integration/YouTubeService.cs +++ b/SubathonManager.Integration/YouTubeService.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; using SubathonManager.Core; using SubathonManager.Core.Enums; @@ -246,7 +247,7 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) { SubathonEvent subathonEvent = new() { User = user, Currency = $"{currency}".Trim().ToUpper(), - Value = $"{item.Superchat.AmountValue}", + Value = string.Create(CultureInfo.InvariantCulture, $"{item.Superchat.AmountValue}"), Source = SubathonEventSource.YouTube, EventType = SubathonEventType.YouTubeSuperChat, Id = Utils.CreateGuidFromUniqueString(item.Id), diff --git a/SubathonManager.Server/WebServer.Api.Leaderboard.cs b/SubathonManager.Server/WebServer.Api.Leaderboard.cs index 8fbca5fb..8d9883ec 100644 --- a/SubathonManager.Server/WebServer.Api.Leaderboard.cs +++ b/SubathonManager.Server/WebServer.Api.Leaderboard.cs @@ -207,7 +207,7 @@ private async Task> BuildLeaderboardEntries entry.Points += ev.GetFinalPointsValue(); if (needsTokens && - double.TryParse(ev.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double tokenValue)) + Utils.TryParseAmount(ev.Value, out double tokenValue)) entry.Tokens += tokenValue * (ev.EventType.IsOrder() ? 1 : Math.Max(ev.Amount, 0)); if (!needsMoney) continue; @@ -409,7 +409,7 @@ private bool IsCommissionAsDonation(SubathonEvent ev, Dictionary<(SubathonEventT return (commission, commissionCurrency); if (IsCurrencyCode(ev.Currency) && - double.TryParse(ev.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double value)) + Utils.TryParseAmount(ev.Value, out double value)) return (value, ev.Currency!.ToUpperInvariant().Trim()); if (TrySplitSecondaryValue(ev, out double secondary, out string secondaryCurrency)) @@ -425,7 +425,7 @@ private static bool TrySplitSecondaryValue(SubathonEvent ev, out double amount, string[] parts = ev.SecondaryValue.Split('|'); if (parts.Length < 2 || !IsCurrencyCode(parts[1])) return false; - if (!double.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out amount)) return false; + if (!Utils.TryParseAmount(parts[0], out amount)) return false; currency = parts[1].ToUpperInvariant().Trim(); return true; diff --git a/SubathonManager.Server/WebServer.Api.cs b/SubathonManager.Server/WebServer.Api.cs index d1ff8093..233a107b 100644 --- a/SubathonManager.Server/WebServer.Api.cs +++ b/SubathonManager.Server/WebServer.Api.cs @@ -3,6 +3,7 @@ using System.Web; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Models; @@ -148,6 +149,8 @@ private async Task HandleDataControlRequestAsync(IHttpContext ctx) { return; } + ExternalEventService.NotifySourceSeen(data); + var type = SubathonEventType.Unknown; if (!data.ContainsKey("type") || !data.TryGetValue("type", out JsonElement elem) || !Enum.TryParse(elem.GetString()!, true, out type)) { @@ -335,7 +338,7 @@ static string NormalizeTier(string meta) { t => t.Key, t => { double sum = t.Sum(e => - double.TryParse(e.Value, out double amount) + Utils.TryParseAmount(e.Value, out double amount) ? amount : 0 ); @@ -361,7 +364,7 @@ static string NormalizeTier(string meta) { t => t.Key, t => { double sum = t.Sum(e => - double.TryParse(string.Equals(e.Value, "new", StringComparison.OrdinalIgnoreCase) + Utils.TryParseAmount(string.Equals(e.Value, "new", StringComparison.OrdinalIgnoreCase) ? "1" : e.Value, out double amount) ? amount diff --git a/SubathonManager.Services/CommandService.cs b/SubathonManager.Services/CommandService.cs index 9a64be83..30743bde 100644 --- a/SubathonManager.Services/CommandService.cs +++ b/SubathonManager.Services/CommandService.cs @@ -1,4 +1,5 @@ -using IniParser.Model; +using System.Globalization; +using IniParser.Model; using Microsoft.Extensions.DependencyInjection; using SubathonManager.Core; using SubathonManager.Core.Enums; @@ -106,12 +107,12 @@ private static bool ValidateParameters(SubathonEvent subathonEvent, string messa switch (subathonEvent.Command) { case SubathonCommandType.AddMoney: case SubathonCommandType.SubtractMoney: - if (parts.Length >= 3 && double.TryParse(parts[1], out double value)) { + if (parts.Length >= 3 && Utils.TryParseAmount(parts[1], out double value)) { if (value <= 0) break; var currencyService = AppServices.Provider.GetRequiredService(); string currency = parts[2]; if (!currencyService.IsValidCurrency(currency)) break; - subathonEvent.Value = $"{value:N2}"; + subathonEvent.Value = value.ToString("F2", CultureInfo.InvariantCulture); subathonEvent.Currency = currency.ToUpper().Trim(); // event service sets it from command to donation adjustment isValid = true; @@ -162,7 +163,7 @@ private static bool ValidateParameters(SubathonEvent subathonEvent, string messa foreach (string part in parts) { if (part.ToLower().Contains('x') && multiplier <= double.MinValue + 5) { - if (!double.TryParse(part.ToLower().Split('x')[0].Trim(), out multiplier)) { + if (!Utils.TryParseAmount(part.ToLower().Split('x')[0], out multiplier)) { multiplier = double.MinValue; continue; } @@ -187,7 +188,8 @@ private static bool ValidateParameters(SubathonEvent subathonEvent, string messa if (multiplier <= double.MinValue + 5) return false; TimeSpan duration = Utils.ParseDurationString(durationString); durationString = duration == TimeSpan.Zero ? "x" : ((int)duration.TotalSeconds).ToString(); - var dataStr = $"{multiplier}|{durationString}s|{applyPoints}|{applyTime}"; + var dataStr = + $"{multiplier.ToString(CultureInfo.InvariantCulture)}|{durationString}s|{applyPoints}|{applyTime}"; subathonEvent.Value = dataStr; isValid = true; diff --git a/SubathonManager.Services/DiscordWebhookService.cs b/SubathonManager.Services/DiscordWebhookService.cs index 1e90785b..a8be49fd 100644 --- a/SubathonManager.Services/DiscordWebhookService.cs +++ b/SubathonManager.Services/DiscordWebhookService.cs @@ -198,7 +198,7 @@ private void OnSubathonEventDeleted(List? subathonEvents) { _currencyService.IsValidCurrency(subathonEvent.Currency)) { double result = Task.Run(async () => { return await _currencyService.ConvertAsync( - double.Parse(subathonEvent.Value), + Utils.ParseAmount(subathonEvent.Value), subathonEvent.Currency!, currency); }).GetAwaiter().GetResult(); totalMoney += result; diff --git a/SubathonManager.Services/EventService.cs b/SubathonManager.Services/EventService.cs index 12ce91c8..bb9d5b9c 100644 --- a/SubathonManager.Services/EventService.cs +++ b/SubathonManager.Services/EventService.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SubathonManager.Core; @@ -36,6 +37,7 @@ public EventService(IDbContextFactory factory, ILogger _logger?.LogError("Event loop crashed: {AggregateException}", t.Exception), @@ -46,6 +48,8 @@ public Task StartAsync(CancellationToken ct = default) { } public async Task StopAsync(CancellationToken ct = default) { + SubathonEvents.SubathonEventCreated -= AddSubathonEvent; + SubathonEvents.SubathonEventCancelled -= OnSubathonEventCancelled; if (!_cts.IsCancellationRequested) _cts.Cancel(); _signal.Release(); @@ -89,9 +93,17 @@ private async Task LoopAsync() { } if (next == null) continue; - (bool wasEffective, bool dupeUneeded) = await ProcessSubathonEvent(next); - if (!dupeUneeded) - SubathonEvents.RaiseSubathonEventProcessed(next, wasEffective); + try { + (bool wasEffective, bool dupeUneeded) = await ProcessSubathonEvent(next); + if (!dupeUneeded) + SubathonEvents.RaiseSubathonEventProcessed(next, wasEffective); + } + catch (Exception ex) when (ex is not OperationCanceledException) { + _logger?.LogError(ex, "Failed to process {EventType} event {Id} from {Source}", + next.EventType, next.Id, next.Source); + ErrorMessageEvents.RaiseErrorEvent("ERROR", next.Source.ToString(), + $"Failed to process {next.EventType} event: {ex.Message}", DateTime.Now); + } } } catch (OperationCanceledException ex) { @@ -112,7 +124,7 @@ private async Task LoopAsync() { if (dupeCheck is { ProcessedToSubathon: true }) return (false, true); SubathonGoalSet? goalSet = await db.SubathonGoalSets.Include(s => s.Goals).AsNoTracking() - .SingleOrDefaultAsync(s => s.IsActive); + .FirstOrDefaultAsync(s => s.IsActive); double initialMoney = subathon!.GetRoundedMoneySumWithCents(); long initialPoints = subathon?.Points ?? 0; @@ -185,7 +197,7 @@ private async Task LoopAsync() { ev.EventType != SubathonEventType.DonationAdjustment) { /////////////////////////////////////////////////////////////// ev.PointsValue = (int)Math.Floor(subathonValue!.Points); - if (double.TryParse(ev.Value, out double parsedValue) + if (Utils.TryParseAmount(ev.Value, out double parsedValue) && ev.Currency != "viewers" && ev.Currency != "sub" && ev.Currency != "member" && ev.Currency != "order" // allow items from orders && !_currencyService.IsValidCurrency(ev.Currency) @@ -208,7 +220,7 @@ private async Task LoopAsync() { .IsOrder())) // includes orders when parsed as money mode { double rate = Task.Run(() => - _currencyService.ConvertAsync(double.Parse(ev.Value), ev.Currency)).Result; + _currencyService.ConvertAsync(Utils.ParseAmount(ev.Value), ev.Currency)).Result; ev.SecondsValue = Math.Round(subathonValue.Seconds * rate, 2); ev.PointsValue = (int)Math.Floor(subathonValue!.Points * rate); } @@ -230,7 +242,7 @@ private async Task LoopAsync() { ev.Currency = "???"; } - if (ev.EventType.IsToken() && double.TryParse(ev.Value, out double parsedBitsLikeValue)) + if (ev.EventType.IsToken() && Utils.TryParseAmount(ev.Value, out double parsedBitsLikeValue)) // seconds in sub value are stored as 0.12 so it is done above ev.PointsValue = (int)Math.Floor(parsedBitsLikeValue / 100 * subathonValue!.Points); } @@ -244,22 +256,22 @@ private async Task LoopAsync() { DateTime cutoff = DateTime.Now.AddDays(-3); // find if same tier, user, and is processed in last 3d, if so, return. Will be diff id's. // we check this late in case they co-processed - SubathonEvent? dupeTwitchSub = await db.SubathonEvents.AsNoTracking().SingleOrDefaultAsync(s => + bool dupeTwitchSub = await db.SubathonEvents.AnyAsync(s => s.Source == ev.Source && s.User == ev.User && s.Value == ev.Value && s.EventType == ev.EventType && s.ProcessedToSubathon && s.SubathonId == subathon.Id && s.EventTimestamp > cutoff); - if (dupeTwitchSub != null) return (false, true); + if (dupeTwitchSub) return (false, true); } if (ev.EventType.IsFollow() && ev.User != "SYSTEM") { - SubathonEvent? dupeFollowType = await db.SubathonEvents.AsNoTracking().SingleOrDefaultAsync(s => + bool dupeFollowType = await db.SubathonEvents.AnyAsync(s => s.Source == ev.Source && s.User == ev.User && s.EventType == ev.EventType && s.ProcessedToSubathon && s.SubathonId == subathon.Id); - if (dupeFollowType != null) return (false, true); + if (dupeFollowType) return (false, true); } var affected = 0; @@ -309,7 +321,7 @@ private async Task LoopAsync() { string value = ev.SecondaryValue.Split('|')[0]; string currency = ev.SecondaryValue.Split('|')[1]; if (_currencyService.IsValidCurrency(currency)) { - double added = await _currencyService.ConvertAsync(double.Parse(value), currency, + double added = await _currencyService.ConvertAsync(Utils.ParseAmount(value), currency, _config.Get("Currency", "Primary", "USD")); affected += await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET MoneySum = MoneySum + {0} WHERE IsActive = 1 AND IsLocked = {2} AND Id = {1}", @@ -319,7 +331,7 @@ private async Task LoopAsync() { else if (ev.EventType.IsCurrencyDonation() && ev.Currency != "???" && _currencyService.IsValidCurrency(ev.Currency) && !string.IsNullOrWhiteSpace(ev.Currency)) { string value = ev.Value; - double added = await _currencyService.ConvertAsync(double.Parse(value), ev.Currency, + double added = await _currencyService.ConvertAsync(Utils.ParseAmount(value), ev.Currency, _config.Get("Currency", "Primary", "USD")); affected += await db.Database.ExecuteSqlRawAsync( @@ -327,7 +339,7 @@ private async Task LoopAsync() { added, subathon.Id, lockVal); } else if (asDono) { - double added = await _currencyService.ConvertAsync(double.Parse(ev.Value) * modifier / 100, "USD", + double added = await _currencyService.ConvertAsync(Utils.ParseAmount(ev.Value) * modifier / 100, "USD", _config.Get("Currency", "Primary", "USD")); affected += await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET MoneySum = MoneySum + {0} WHERE IsActive = 1 AND IsLocked = {2} AND Id = {1}", @@ -352,15 +364,6 @@ private async Task LoopAsync() { await db.SaveChangesAsync(); - await db.Entry(subathon).ReloadAsync(); - double newMoney = subathon.GetRoundedMoneySumWithCents(); - if (newMoney < initialMoney || initialMoney < newMoney) { - long pts = subathon.Points; - if (goalSet?.Type == GoalsType.Money) pts = subathon.GetRoundedMoneySum(); - await CheckForGoalChange(db, pts, initialPoints); - SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); - } - db.Entry(subathon).State = EntityState.Detached; if (affected > 0 || ev.ProcessedToSubathon) @@ -376,7 +379,7 @@ private async Task LoopAsync() { TimeSpan? duration = null; bool applyPts = _config.GetBool("Twitch", "HypeTrainMultiplier.Points"); bool applyTime = _config.GetBool("Twitch", "HypeTrainMultiplier.Time"); - double.TryParse(_config.Get("Twitch", "HypeTrainMultiplier.Multiplier", "1"), + Utils.TryParseAmount(_config.Get("Twitch", "HypeTrainMultiplier.Multiplier", "1"), out double parsedAmt); if (!(subathon.Multiplier.IsRunning() && !parsedAmt.Equals(1) @@ -422,15 +425,13 @@ await db.Database.ExecuteSqlRawAsync( break; case SubathonCommandType.AddSpins: if (ev.Amount < 1) return (false, false, true); - int spins = await StateValueHelper.GetAsync(_factory, StateKeys.WheelSpinsOwed, 0); - await StateValueHelper.SetAsync(_factory, StateKeys.WheelSpinsOwed, spins + ev.Amount); - WheelEvents.RaiseSpinsOwedUpdateFromEvent(ev.Amount + spins); + WheelEvents.RaiseSpinsOwedUpdateFromEvent( + await StateValueHelper.AddIntAsync(_factory, StateKeys.WheelSpinsOwed, ev.Amount)); break; case SubathonCommandType.SubtractSpins: if (ev.Amount < 1) return (false, false, true); - int spins2 = await StateValueHelper.GetAsync(_factory, StateKeys.WheelSpinsOwed, 0); - await StateValueHelper.SetAsync(_factory, StateKeys.WheelSpinsOwed, int.Max(0, spins2 - ev.Amount)); - WheelEvents.RaiseSpinsOwedUpdateFromEvent(int.Max(0, spins2 - ev.Amount)); + WheelEvents.RaiseSpinsOwedUpdateFromEvent( + await StateValueHelper.AddIntAsync(_factory, StateKeys.WheelSpinsOwed, -ev.Amount)); break; case SubathonCommandType.SpinWheel: WheelEvents.RaiseWheelSpinRequested(); @@ -440,9 +441,9 @@ await db.Database.ExecuteSqlRawAsync( break; case SubathonCommandType.SubtractMoney: ev.EventType = SubathonEventType.DonationAdjustment; - double.TryParse(ev.Value, out double moneyVal); + Utils.TryParseAmount(ev.Value, out double moneyVal); if (moneyVal > 0) moneyVal *= -1; - ev.Value = $"{moneyVal:N2}"; + ev.Value = moneyVal.ToString("F2", CultureInfo.InvariantCulture); break; case SubathonCommandType.SetPoints: if (ev.PointsValue < 0) return (false, false, true); @@ -514,7 +515,7 @@ await db.Database.ExecuteSqlRawAsync( case SubathonCommandType.SetMultiplier: // string dataStr = $"{parsedAmt}|{durationStr}s|{applyPts}|{applyTime}"; string[] data = ev.Value.Split("|"); - if (!double.TryParse(data[0], out double parsedAmt)) + if (data.Length < 4 || !Utils.TryParseAmount(data[0], out double parsedAmt)) return (false, false, true); TimeSpan? duration; if (data[1] == "xs") @@ -589,6 +590,38 @@ private static async Task CheckForGoalChange(AppDbContext db, long newPoints, lo await Task.CompletedTask; } + private void OnSubathonEventCancelled(Guid id, SubathonEventType type, string reference) { + Task.Run(async () => { + try { + await DeleteCancelledEventAsync(id, type, reference); + } + catch (Exception ex) { + _logger?.LogError(ex, "Failed to remove cancelled {EventType} {Reference}", type, reference); + } + }); + } + + public async Task DeleteCancelledEventAsync(Guid id, SubathonEventType type, string reference) { + await using AppDbContext db = await _factory.CreateDbContextAsync(); + SubathonEvent? ev = await db.SubathonEvents.FirstOrDefaultAsync(e => e.Id == id && e.EventType == type); + if (ev == null) { + _logger?.LogDebug("Cancelled {EventType} {Reference} was never tracked, nothing to remove", type, reference); + return false; + } + + bool inActive = ev.SubathonId != null && + await db.SubathonDatas.AnyAsync(s => s.IsActive && s.Id == ev.SubathonId); + string label = ((SubathonEventType?)type).GetLabel(); + string msg = inActive + ? $"{label} {reference} from {ev.User} was cancelled. Removed its event ({ev.Value} {ev.Currency})." + : $"{label} {reference} from {ev.User} was cancelled, but it's from a past subathon, so it will be kept"; + _logger?.LogWarning("{Message}", msg); + ErrorMessageEvents.RaiseErrorEvent("WARN", ev.Source.ToString(), msg, DateTime.Now); + + if (inActive) await DeleteSubathonEvent(db, ev); + return inActive; + } + public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) { if (ev.SubathonId == null) return; @@ -626,12 +659,12 @@ public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) { string value = ev.SecondaryValue.Split('|')[0]; string currency = ev.SecondaryValue.Split('|')[1]; if (_currencyService.IsValidCurrency(currency)) - moneyToRemove += await _currencyService.ConvertAsync(double.Parse(value), currency, + moneyToRemove += await _currencyService.ConvertAsync(Utils.ParseAmount(value), currency, _config.Get("Currency", "Primary", "USD")); } else if (ev.EventType.IsCurrencyDonation() && _currencyService.IsValidCurrency(ev.Currency) && ev.ProcessedToSubathon) { - moneyToRemove += await _currencyService.ConvertAsync(double.Parse(ev.Value), ev.Currency!, + moneyToRemove += await _currencyService.ConvertAsync(Utils.ParseAmount(ev.Value), ev.Currency!, _config.Get("Currency", "Primary", "USD")); } @@ -639,7 +672,7 @@ public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) { // this is acceptable for now, as it can resync properly on toggle (bool asDono, double modifier) = Utils.GetAltCurrencyUseAsDonation(_config, ev.EventType); if (asDono) - moneyToRemove += await _currencyService.ConvertAsync(Math.Round(double.Parse(ev.Value) * modifier / 100, 2), + moneyToRemove += await _currencyService.ConvertAsync(Math.Round(Utils.ParseAmount(ev.Value) * modifier / 100, 2), "USD", _config.Get("Currency", "Primary", "USD")); @@ -720,7 +753,7 @@ public async Task UndoSimulatedEvents(AppDbContext db, List event pointsToRemove += (int)ev.GetFinalPointsValue(); if (ev.EventType.IsCurrencyDonation()) { moneyToRemove += - await _currencyService.ConvertAsync(double.Parse(ev.Value), ev.Currency!, subathon.Currency!); + await _currencyService.ConvertAsync(Utils.ParseAmount(ev.Value), ev.Currency!, subathon.Currency!); } else if (Utils.IsCommissionAsDonation(_config, ev) && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && @@ -729,7 +762,7 @@ public async Task UndoSimulatedEvents(AppDbContext db, List event string currency = ev.SecondaryValue.Split('|')[1]; if (_currencyService.IsValidCurrency(currency)) moneyToRemove += - await _currencyService.ConvertAsync(double.Parse(value), currency, subathon.Currency!); + await _currencyService.ConvertAsync(Utils.ParseAmount(value), currency, subathon.Currency!); } (bool asDono, double modifier) = Utils.GetAltCurrencyUseAsDonation(_config, ev.EventType); diff --git a/SubathonManager.Services/WheelSpinTriggerService.cs b/SubathonManager.Services/WheelSpinTriggerService.cs index 8c3808f3..c99a4217 100644 --- a/SubathonManager.Services/WheelSpinTriggerService.cs +++ b/SubathonManager.Services/WheelSpinTriggerService.cs @@ -85,9 +85,7 @@ private async Task ProcessTriggers(SubathonEvent ev, SubathonEventSubType subTyp int spinsToAdd = await CalculateSpins(ev, trigger, subType); if (spinsToAdd < 1) return; - var currentSpins = await StateValueHelper.GetAsync(factory, StateKeys.WheelSpinsOwed); - int newSpins = currentSpins + spinsToAdd; - await StateValueHelper.SetAsync(factory, StateKeys.WheelSpinsOwed, newSpins); + int newSpins = await StateValueHelper.AddIntAsync(factory, StateKeys.WheelSpinsOwed, spinsToAdd); var history = new WheelSpinTriggerHistory { TriggerId = trigger.Id, @@ -125,7 +123,7 @@ private async Task CalculateSpins(SubathonEvent ev, WheelSpinTrigger trigge case SubathonEventSubType.TokenLike: { if (trigger.CountThreshold is null or <= 0) return 0; - if (!double.TryParse(ev.Value, out double tokenCount)) return 0; + if (!Utils.TryParseAmount(ev.Value, out double tokenCount)) return 0; int multiplier = (int)tokenCount / trigger.CountThreshold.Value; return multiplier * trigger.SpinsToAdd; } @@ -140,7 +138,7 @@ private async Task CalculateSpins(SubathonEvent ev, WheelSpinTrigger trigge if (trigger.MoneyThreshold is > 0 && !string.IsNullOrEmpty(trigger.Currency) && !string.IsNullOrEmpty(ev.Currency) && !string.IsNullOrEmpty(ev.Value)) { - if (!double.TryParse(ev.Value, out double orderValue)) return 0; + if (!Utils.TryParseAmount(ev.Value, out double orderValue)) return 0; double converted = await currencyService.ConvertAsync(orderValue, ev.Currency, trigger.Currency); var multiplier = (int)(converted / trigger.MoneyThreshold.Value); return multiplier * trigger.SpinsToAdd; @@ -153,7 +151,7 @@ private async Task CalculateSpins(SubathonEvent ev, WheelSpinTrigger trigge case SubathonEventSubType.DonationLike: { if (trigger.MoneyThreshold is null or <= 0) return 0; if (string.IsNullOrEmpty(trigger.Currency) || string.IsNullOrEmpty(ev.Currency)) return 0; - if (!double.TryParse(ev.Value, out double donationValue)) return 0; + if (!Utils.TryParseAmount(ev.Value, out double donationValue)) return 0; double converted = await currencyService.ConvertAsync(donationValue, ev.Currency, trigger.Currency); var multiplier = (int)(converted / trigger.MoneyThreshold.Value); return multiplier * trigger.SpinsToAdd; diff --git a/SubathonManager.Tests/CoreUnitTests/ScheduleCsvTests.cs b/SubathonManager.Tests/CoreUnitTests/ScheduleCsvTests.cs new file mode 100644 index 00000000..ce82d663 --- /dev/null +++ b/SubathonManager.Tests/CoreUnitTests/ScheduleCsvTests.cs @@ -0,0 +1,174 @@ +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + +namespace SubathonManager.Tests.CoreUnitTests; + +public class ScheduleCsvTests { + private static readonly DateTime Day = new(2026, 9, 23); + + [Fact] + public void Write_UsesDbColumnNamesInOrder() { + string csv = ScheduleCsv.Write([]); + Assert.Equal("Date,Kind,StartMinute,EndMinute,Title,Description,IsDone", csv.TrimEnd()); + } + + [Fact] + public void Write_ThenParse_RoundTripsInOrder() { + var items = new List { + new() { + Date = Day, Kind = ScheduleItemKind.Event, StartMinute = 22 * 60, EndMinute = 2 * 60, + Title = "Late stream", Description = "line one\nline \"two\", with comma", IsDone = true + }, + new() { Date = Day, Kind = ScheduleItemKind.Task, Title = "Prep overlay" }, + new() { Date = Day.AddDays(1), Kind = ScheduleItemKind.Event, StartMinute = 9 * 60, Title = "Breakfast" } + }; + + ScheduleCsv.ParseResult parsed = ScheduleCsv.Parse(ScheduleCsv.Write(items)); + + Assert.Empty(parsed.Errors); + Assert.Equal(3, parsed.Items.Count); + for (var i = 0; i < items.Count; i++) { + Assert.Equal(items[i].Date, parsed.Items[i].Date); + Assert.Equal(items[i].Kind, parsed.Items[i].Kind); + Assert.Equal(items[i].StartMinute, parsed.Items[i].StartMinute); + Assert.Equal(items[i].EndMinute, parsed.Items[i].EndMinute); + Assert.Equal(items[i].Title, parsed.Items[i].Title); + Assert.Equal(items[i].Description, parsed.Items[i].Description); + Assert.Equal(items[i].IsDone, parsed.Items[i].IsDone); + } + } + + [Fact] + public void Parse_ColumnsMatchByNameAndOptionalOnesDefault() { + const string csv = "title,DATE\nJust a title,2026-09-23\n"; + ScheduleCsv.ParseResult parsed = ScheduleCsv.Parse(csv); + + Assert.Empty(parsed.Errors); + ScheduleItem item = Assert.Single(parsed.Items); + Assert.Equal("Just a title", item.Title); + Assert.Equal(Day, item.Date); + Assert.Equal(ScheduleItemKind.Event, item.Kind); + Assert.Null(item.StartMinute); + Assert.False(item.IsDone); + } + + [Fact] + public void Parse_MissingRequiredColumn_ReportsError() { + ScheduleCsv.ParseResult parsed = ScheduleCsv.Parse("Date,Kind\n2026-09-23,Event\n"); + Assert.Empty(parsed.Items); + Assert.Contains(parsed.Errors, e => e.Contains("Title")); + } + + [Fact] + public void Parse_BadRows_AreSkippedWithLineNumbers() { + const string csv = "Date,Kind,StartMinute,EndMinute,Title,Description,IsDone\n" + + "not-a-date,Event,,,A,,false\n" + + "2026-09-23,Party,,,B,,false\n" + + "2026-09-23,Event,25:00,,C,,false\n" + + "2026-09-23,Event,,,,,false\n" + + "2026-09-23,Task,,,Good,,true\n"; + + ScheduleCsv.ParseResult parsed = ScheduleCsv.Parse(csv); + + ScheduleItem item = Assert.Single(parsed.Items); + Assert.Equal("Good", item.Title); + Assert.True(item.IsDone); + Assert.Equal(4, parsed.Errors.Count); + Assert.StartsWith("Line 2:", parsed.Errors[0]); + Assert.StartsWith("Line 5:", parsed.Errors[3]); + } + + [Fact] + public void Parse_EndWithoutStart_IsDropped() { + ScheduleCsv.ParseResult parsed = ScheduleCsv.Parse("Date,Title,EndMinute\n2026-09-23,X,10:00\n"); + Assert.Null(Assert.Single(parsed.Items).EndMinute); + } + + [Fact] + public void Parse_IgnoresBomAndBlankLines() { + ScheduleCsv.ParseResult parsed = ScheduleCsv.Parse((char)0xFEFF + "Date,Title\r\n\r\n2026-09-23,X\r\n\r\n"); + Assert.Empty(parsed.Errors); + Assert.Single(parsed.Items); + } + + [Fact] + public void PlanImport_ExactMatchWithSameDescription_IsIgnored() { + var existing = new ScheduleItem { Date = Day, Title = "Stream", StartMinute = 600, Description = "d" }; + var incoming = new ScheduleItem { Date = Day, Title = "Stream", StartMinute = 600, Description = "d" }; + + ScheduleCsv.ImportPlan plan = ScheduleCsv.PlanImport([existing], [incoming]); + + Assert.Empty(plan.ToAdd); + Assert.Empty(plan.ToUpdate); + Assert.Equal(1, plan.Unchanged); + } + + [Fact] + public void PlanImport_ExactMatchWithNewDescription_UpdatesOnlyDescription() { + var existing = new ScheduleItem { + Date = Day, Title = "Stream", StartMinute = 600, Description = "old", IsDone = true, + Kind = ScheduleItemKind.Event + }; + var incoming = new ScheduleItem { + Date = Day, Title = "Stream", StartMinute = 600, Description = "new", IsDone = false, + Kind = ScheduleItemKind.Task + }; + + ScheduleCsv.ImportPlan plan = ScheduleCsv.PlanImport([existing], [incoming]); + + Assert.Same(existing, Assert.Single(plan.ToUpdate)); + Assert.Empty(plan.ToAdd); + Assert.Equal("new", existing.Description); + Assert.True(existing.IsDone); + Assert.Equal(ScheduleItemKind.Event, existing.Kind); + } + + [Theory] + [InlineData("Stream ", 600, null)] + [InlineData("stream", 600, null)] + [InlineData("Stream", 601, null)] + [InlineData("Stream", 600, 700)] + [InlineData("Stream", null, null)] + public void PlanImport_AnyDifferenceInSlot_Adds(string title, int? start, int? end) { + var existing = new ScheduleItem { Date = Day, Title = "Stream", StartMinute = 600 }; + var incoming = new ScheduleItem { Date = Day, Title = title, StartMinute = start, EndMinute = end }; + + ScheduleCsv.ImportPlan plan = ScheduleCsv.PlanImport([existing], [incoming]); + + Assert.Single(plan.ToAdd); + Assert.Empty(plan.ToUpdate); + } + + [Fact] + public void PlanImport_OtherDay_Adds() { + var existing = new ScheduleItem { Date = Day, Title = "Stream" }; + var incoming = new ScheduleItem { Date = Day.AddDays(1), Title = "Stream" }; + Assert.Single(ScheduleCsv.PlanImport([existing], [incoming]).ToAdd); + } + + [Fact] + public void PlanImport_AppendsAfterExistingInReadOrder() { + var existing = new ScheduleItem { Date = Day, Title = "First", SortOrder = 4 }; + var a = new ScheduleItem { Date = Day, Title = "A" }; + var b = new ScheduleItem { Date = Day.AddDays(1), Title = "B" }; + var c = new ScheduleItem { Date = Day, Title = "C" }; + + ScheduleCsv.ImportPlan plan = ScheduleCsv.PlanImport([existing], [a, b, c]); + + Assert.Equal(new[] { a, b, c }, plan.ToAdd); + Assert.Equal(5, a.SortOrder); + Assert.Equal(6, c.SortOrder); + Assert.Equal(0, b.SortOrder); + } + + [Fact] + public void PlanImport_DuplicateRowsInSameFile_AddOnce() { + var a = new ScheduleItem { Date = Day, Title = "A", Description = "x" }; + var again = new ScheduleItem { Date = Day, Title = "A", Description = "x" }; + + ScheduleCsv.ImportPlan plan = ScheduleCsv.PlanImport([], [a, again]); + + Assert.Single(plan.ToAdd); + Assert.Equal(1, plan.Unchanged); + } +} diff --git a/SubathonManager.Tests/CoreUnitTests/ScheduleItemTests.cs b/SubathonManager.Tests/CoreUnitTests/ScheduleItemTests.cs new file mode 100644 index 00000000..e995b573 --- /dev/null +++ b/SubathonManager.Tests/CoreUnitTests/ScheduleItemTests.cs @@ -0,0 +1,78 @@ +using SubathonManager.Core.Models; + +namespace SubathonManager.Tests.CoreUnitTests; + +public class ScheduleItemTests { + [Theory] + [InlineData("9", 9 * 60)] + [InlineData("09", 9 * 60)] + [InlineData("930", 9 * 60 + 30)] + [InlineData("0930", 9 * 60 + 30)] + [InlineData("9:30", 9 * 60 + 30)] + [InlineData("21:05", 21 * 60 + 5)] + [InlineData("21.05", 21 * 60 + 5)] + [InlineData("7:", 7 * 60)] + [InlineData(" 23:59 ", 23 * 60 + 59)] + [InlineData("0:00", 0)] + public void TryParseTime_ValidInput_ReturnsMinutes(string input, int expected) { + Assert.True(ScheduleItem.TryParseTime(input, out int? minute)); + Assert.Equal(expected, minute); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryParseTime_Blank_ReturnsTrueWithNull(string? input) { + Assert.True(ScheduleItem.TryParseTime(input, out int? minute)); + Assert.Null(minute); + } + + [Theory] + [InlineData("24")] + [InlineData("24:00")] + [InlineData("12:60")] + [InlineData("960")] + [InlineData("12345")] + [InlineData("ab")] + [InlineData("1:2:3")] + [InlineData("-1")] + public void TryParseTime_Invalid_ReturnsFalse(string input) { + Assert.False(ScheduleItem.TryParseTime(input, out int? minute)); + Assert.Null(minute); + } + + [Theory] + [InlineData(0, "00:00")] + [InlineData(9 * 60 + 5, "09:05")] + [InlineData(1440, "00:00")] + [InlineData(-30, "23:30")] + public void FormatMinute_WrapsAndPads(int minute, string expected) { + Assert.Equal(expected, ScheduleItem.FormatMinute(minute)); + } + + [Fact] + public void TimeLabel_AllDay() { + var item = new ScheduleItem(); + Assert.True(item.IsAllDay); + Assert.Equal("All day", item.TimeLabel()); + } + + [Fact] + public void TimeLabel_StartOnly() { + var item = new ScheduleItem { StartMinute = 14 * 60 }; + Assert.Equal("14:00", item.TimeLabel()); + } + + [Fact] + public void TimeLabel_StartAndEnd() { + var item = new ScheduleItem { StartMinute = 14 * 60, EndMinute = 16 * 60 + 30 }; + Assert.Equal("14:00 - 16:30", item.TimeLabel()); + } + + [Fact] + public void TimeLabel_PastMidnight_MarksNextDay() { + var item = new ScheduleItem { StartMinute = 22 * 60, EndMinute = 2 * 60 }; + Assert.Equal("22:00 - 02:00 (+1)", item.TimeLabel()); + } +} diff --git a/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs index dc49d42a..4b4b193f 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs @@ -795,6 +795,7 @@ public void ProcessExternalOrder_RespectsModeConfig(string mode, string expected Assert.Equal("Buyer", ev!.User); Assert.Equal(expectedValue, ev.Value); Assert.Equal(expectedCurrency, ev.Currency); + Assert.Equal("25|USD", ev.SecondaryValue); Assert.Equal(2, ev.Amount); Assert.Equal(SubathonEventSource.KoFi, ev.Source); Assert.Equal(SubathonEventType.KoFiShopOrder, ev.EventType); diff --git a/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs b/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs index 7ae953bd..2a743dd5 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs @@ -14,6 +14,7 @@ using Fourthwall.Client.Generated.Models.Openapi.Model.OfferAbstractV1.OfferVariantAbstractV1; using Fourthwall.Client.Generated.Models.Openapi.Model.OrderV1; using Fourthwall.Client.Generated.Models.Openapi.Model.OrderV1.Source; +using Fourthwall.Client.Models; using Microsoft.Extensions.Logging; using Moq; using SubathonManager.Core.Enums; @@ -554,6 +555,44 @@ public void MapToSubathonEvent_MembershipPurchased_TestMode_ProducesUniqueGuidEa Assert.NotEqual(ev1!.Id, ev2!.Id); } + [Theory] + [InlineData(true, OrderV1_status.CANCELLED, true)] + [InlineData(false, OrderV1_status.CANCELLED, false)] + [InlineData(true, OrderV1_status.SHIPPED, false)] + public void HandleOrderUpdated_CancelledOrder_RaisesCancelForPlacedEventId(bool enabled, OrderV1_status status, + bool expectRaised) { + (FourthWallService service, _) = MakeService(new Dictionary<(string, string), string> { + { ("FourthWall", FourthWallService.AutoDeleteCancelledKey), $"{enabled}" } + }); + FourthwallOrderPlacedWebhookEvent placed = MakeOrderEvent(id: "ord_abc123"); + SubathonEvent? placedEv = service.MapToSubathonEvent(placed); + + placed.Data.Status = status; + placed.Data.FriendlyId = "FW-1234"; + var updated = new FourthwallOrderUpdatedWebhookEvent { + Id = Guid.NewGuid().ToString(), + Data = new OrderUpdatedV1 { Order = placed.Data, Update = new OrderUpdatedV1Update { Type = "STATUS" } }, + CreatedAt = DateTimeOffset.UtcNow, + TestMode = false, + WebhookId = "", ShopId = "", Type = "ORDER_UPDATED", ApiVersion = "" + }; + + (Guid Id, SubathonEventType Type, string Reference)? raised = null; + void OnCancelled(Guid id, SubathonEventType type, string reference) { + raised = (id, type, reference); + } + SubathonEvents.SubathonEventCancelled += OnCancelled; + bool result = service.HandleOrderUpdated(updated); + SubathonEvents.SubathonEventCancelled -= OnCancelled; + + Assert.Equal(expectRaised, result); + Assert.Equal(expectRaised, raised != null); + if (!expectRaised) return; + Assert.Equal(placedEv!.Id, raised!.Value.Id); + Assert.Equal(SubathonEventType.FourthWallOrder, raised.Value.Type); + Assert.Equal("#FW-1234", raised.Value.Reference); + } + [Fact] public async Task HandleWebhookAsync_ForwardsToConfiguredUrl() { await using MockWebServerHost mockServer = new MockWebServerHost().OnPost("/fw-forward", ""); diff --git a/SubathonManager.Tests/IntegrationUnitTests/MixItUpServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/MixItUpServiceTests.cs new file mode 100644 index 00000000..72322334 --- /dev/null +++ b/SubathonManager.Tests/IntegrationUnitTests/MixItUpServiceTests.cs @@ -0,0 +1,468 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Moq; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Integration; +using SubathonManager.Tests.Utility; + +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Tests.IntegrationUnitTests; + +[Collection("GlobalState")] +public class MixItUpServiceTests { + private static readonly Guid CommandId = Guid.Parse("11111111-2222-3333-4444-555555555555"); + + public MixItUpServiceTests() { + foreach (string field in new[] { "ConnectionUpdated", "ExternalSourceSeen" }) + typeof(IntegrationEvents).GetField(field, BindingFlags.Static | BindingFlags.NonPublic) + ?.SetValue(null, null); + foreach (string field in new[] { "SubathonDataUpdate", "PromptRunUpdate", "SubathonEventProcessed" }) + typeof(SubathonEvents).GetField(field, BindingFlags.Static | BindingFlags.NonPublic) + ?.SetValue(null, null); + } + + private static string Id(string name) { + return $"{MixItUpService.IdentifierPrefix}{name}"; + } + + private static MixItUpService MakeService(RecordingHandler? handler = null, + Dictionary<(string, string), string>? config = null, ITimerService? timerService = null) { + handler ??= new RecordingHandler(HttpStatusCode.ServiceUnavailable); + var factory = new Mock(); + factory.Setup(f => f.CreateClient(It.IsAny())).Returns(() => new HttpClient(handler, false)); + return new MixItUpService(new Mock>().Object, + MockConfig.MakeMockConfig(config), factory.Object, timerService ?? new Mock().Object); + } + + private static Dictionary<(string, string), string> EnabledConfig(MixItUpTrigger trigger) { + return new Dictionary<(string, string), string> { + [(MixItUpService.ConfigSection, "Enabled")] = "True", + [(MixItUpService.ConfigSection, MixItUpService.CommandConfigKey(trigger))] = CommandId.ToString() + }; + } + + private static List CaptureConnections(Action trigger) { + var captured = new List(); + + void Handler(IntegrationConnection c) { + if (c.Source == SubathonEventSource.MixItUp) captured.Add(c); + } + + IntegrationEvents.ConnectionUpdated += Handler; + try { + trigger(); + } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + } + + return captured; + } + + [Fact] + public void BuildCommandJson_ProducesImportableActionGroup() { + JsonObject root = JsonNode.Parse(MixItUpCommandExporter.BuildCommandJson(SubathonCommandType.AddTime, 15000))! + .AsObject(); + + Assert.Equal("$type", root.First().Key); + Assert.Equal("MixItUp.Base.Model.Commands.ActionGroupCommandModel, MixItUp.Base", + root["$type"]!.GetValue()); + Assert.Equal("Subathon - Add Time", root["Name"]!.GetValue()); + Assert.Equal(4, root["Type"]!.GetValue()); + + JsonObject action = root["Actions"]!.AsArray().Single()!.AsObject(); + Assert.Equal("$type", action.First().Key); + Assert.Equal("MixItUp.Base.Model.Actions.WebRequestActionModel, MixItUp.Base", + action["$type"]!.GetValue()); + Assert.Equal("http://localhost:15000/api/data/control", action["Url"]!.GetValue()); + Assert.Equal(1, action["HttpMethod"]!.GetValue()); + Assert.Equal(11, action["Type"]!.GetValue()); + } + + [Theory] + [InlineData(SubathonCommandType.AddTime, "$allargs")] + [InlineData(SubathonCommandType.Pause, "")] + public void BuildRequestBody_ParameterOnlyWhenRequired(SubathonCommandType command, string expectedMessage) { + var body = JsonSerializer.Deserialize>( + MixItUpCommandExporter.BuildRequestBody(command))!; + + Assert.Equal("Command", body["type"]); + Assert.Equal(command.ToString(), body["command"]); + Assert.Equal(expectedMessage, body["message"]); + Assert.Equal("$username", body["user"]); + Assert.Equal("MixItUp", body["source"]); + } + + [Fact] + public void WriteAll_WritesEveryCommandAndRemovesStaleFiles() { + string folder = Path.Combine(Path.GetTempPath(), "SubathonManagerTests", $"miu-{Guid.NewGuid():N}"); + try { + Directory.CreateDirectory(folder); + string stale = Path.Combine(folder, $"Old{MixItUpCommandExporter.FileExtension}"); + File.WriteAllText(stale, "{}"); + + IReadOnlyList written = MixItUpCommandExporter.WriteAll(folder, 14040); + + Assert.False(File.Exists(stale)); + Assert.Equal(MixItUpCommandExporter.ExportableCommands.Count(), written.Count); + Assert.DoesNotContain(written, p => p.Contains(nameof(SubathonCommandType.None))); + Assert.All(written, p => Assert.True(File.Exists(p))); + } + finally { + try { + Directory.Delete(folder, true); + } + catch { + /**/ + } + } + } + + [Fact] + public void NotifySourceSeen_RaisesForExternalSourceOnly() { + var seen = new List(); + IntegrationEvents.ExternalSourceSeen += seen.Add; + try { + foreach (string src in new[] { "MixItUp", "Twitch", "NotASource" }) { + var data = JsonSerializer.Deserialize>( + $$"""{ "source": "{{src}}" }""")!; + ExternalEventService.NotifySourceSeen(data); + } + + ExternalEventService.NotifySourceSeen(new Dictionary()); + } + finally { + IntegrationEvents.ExternalSourceSeen -= seen.Add; + } + + Assert.Equal(new[] { SubathonEventSource.MixItUp }, seen); + } + + [Fact] + public void MarkSeen_GoesGreen_ThenGreyWhenSeenTimerFires() { + var timer = new Mock(); + Action? expire = null; + timer.Setup(t => t.Register("mixitup-seen", MixItUpService.SeenWindow, It.IsAny())) + .Callback((string _, TimeSpan _, Action cb) => expire = cb) + .Returns(Mock.Of()); + MixItUpService service = MakeService(timerService: timer.Object); + + List seen = CaptureConnections(() => service.MarkSeen(DateTime.Now)); + Assert.Single(seen); + Assert.True(seen[0].Status); + Assert.True(seen[0].Configured); + Assert.NotNull(expire); + + List expired = CaptureConnections(expire!); + Assert.Single(expired); + Assert.False(expired[0].Status); + Assert.False(expired[0].Configured); + Assert.False(service.Connected); + timer.Verify(t => t.Unregister("mixitup-seen")); + } + + [Fact] + public void MarkSeen_AgainReRegistersWindowWithoutRebroadcasting() { + var timer = new Mock(); + MixItUpService service = MakeService(timerService: timer.Object); + service.MarkSeen(DateTime.Now); + + List again = CaptureConnections(() => service.MarkSeen(DateTime.Now)); + + Assert.Empty(again); + timer.Verify(t => t.Register("mixitup-seen", MixItUpService.SeenWindow, It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task StartAsync_RegistersProbe_StopUnregistersAll() { + var timer = new Mock(); + MixItUpService service = MakeService(timerService: timer.Object); + await service.StartAsync(TestContext.Current.CancellationToken); + await service.StopAsync(TestContext.Current.CancellationToken); + timer.Verify(t => t.Register("mixitup-probe", MixItUpService.ProbeInterval, It.IsAny())); + timer.Verify(t => t.Unregister("mixitup-probe")); + timer.Verify(t => t.Unregister("mixitup-seen")); + } + + [Fact] + public async Task ProbeAsync_Success_MarksSeenWithVersion() { + var handler = new RecordingHandler(HttpStatusCode.OK, "\"1.2.3.4\""); + MixItUpService service = MakeService(handler); + + bool ok = await service.ProbeAsync(TestContext.Current.CancellationToken); + Assert.True(ok); + Assert.True(service.Connected); + Assert.Equal("1.2.3.4", service.Version); + Assert.EndsWith("/api/v2/status/version", handler.Requests.Single().Uri); + service.Dispose(); + } + + [Fact] + public async Task ProbeAsync_Unreachable_FailsSilently() { + MixItUpService service = MakeService(new RecordingHandler(HttpStatusCode.OK, throwConnect: true)); + + List updates = []; + bool ok = true; + Exception? ex = await Record.ExceptionAsync(async () => { + IntegrationEvents.ConnectionUpdated += updates.Add; + try { + ok = await service.ProbeAsync(TestContext.Current.CancellationToken); + } + finally { + IntegrationEvents.ConnectionUpdated -= updates.Add; + } + }); + + Assert.Null(ex); + Assert.False(ok); + Assert.False(service.Connected); + Assert.Empty(updates); + } + + [Theory] + [InlineData("\"1.0.0\"", "1.0.0")] + [InlineData("1.0.0", "1.0.0")] + [InlineData("", null)] + public void ParseVersion_HandlesJsonAndRaw(string body, string? expected) { + Assert.Equal(expected, MixItUpService.ParseVersion(body)); + } + + [Fact] + public void Fire_Disabled_DoesNothing() { + Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.GoalCompleted); + config[(MixItUpService.ConfigSection, "Enabled")] = "False"; + MixItUpService service = MakeService(config: config); + + Assert.False(service.Fire(MixItUpTrigger.GoalCompleted, new Dictionary())); + } + + [Fact] + public void Fire_InvalidCommandId_DoesNothing() { + Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.GoalCompleted); + config[(MixItUpService.ConfigSection, MixItUpService.CommandConfigKey(MixItUpTrigger.GoalCompleted))] = + "not-a-guid"; + MixItUpService service = MakeService(config: config); + + Assert.False(service.Fire(MixItUpTrigger.GoalCompleted, new Dictionary())); + } + + [Fact] + public async Task Fire_PostsCommandWithSpecialIdentifiers() { + var handler = new RecordingHandler(HttpStatusCode.Accepted); + MixItUpService service = MakeService(handler, EnabledConfig(MixItUpTrigger.GoalCompleted)); + + bool fired = service.Fire(MixItUpTrigger.GoalCompleted, + new Dictionary { [Id("goaltext")] = "Hat on" }); + RecordedRequest request = await handler.WaitForRequestAsync(TestContext.Current.CancellationToken); + + Assert.True(fired); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.EndsWith($"/api/v2/commands/{CommandId}", request.Uri); + + JsonObject body = JsonNode.Parse(request.Body)!.AsObject(); + JsonObject identifiers = body["SpecialIdentifiers"]!.AsObject(); + Assert.Equal("GoalCompleted", identifiers[Id("trigger")]!.GetValue()); + Assert.Equal("Hat on", identifiers[Id("goaltext")]!.GetValue()); + Assert.False(body["IgnoreRequirements"]!.GetValue()); + } + + [Fact] + public async Task DataUpdate_FiresOnPauseChangeOnly() { + var handler = new RecordingHandler(HttpStatusCode.Accepted); + Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.TimerPaused); + MixItUpService service = MakeService(handler, config); + await service.StartAsync(TestContext.Current.CancellationToken); + try { + var subathon = new SubathonData { IsPaused = false, IsLocked = false }; + SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); + SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); + subathon.IsPaused = true; + SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); + + RecordedRequest request = await handler.WaitForRequestAsync(TestContext.Current.CancellationToken, + r => r.Method == HttpMethod.Post); + + JsonObject identifiers = JsonNode.Parse(request.Body)!["SpecialIdentifiers"]!.AsObject(); + Assert.Equal("TimerPaused", identifiers[Id("trigger")]!.GetValue()); + Assert.Equal("True", identifiers[Id("paused")]!.GetValue()); + Assert.Single(handler.Requests, r => r.Method == HttpMethod.Post); + } + finally { + await service.StopAsync(TestContext.Current.CancellationToken); + } + } + + [Fact] + public async Task PromptEnded_FiresOnlyForEndedRuns() { + var handler = new RecordingHandler(HttpStatusCode.Accepted); + MixItUpService service = MakeService(handler, EnabledConfig(MixItUpTrigger.PromptEnded)); + await service.StartAsync(TestContext.Current.CancellationToken); + try { + var run = new SubathonPromptRun { Status = SubathonPromptRunStatus.Active }; + SubathonEvents.RaisePromptRunUpdate(run, null); + run.Status = SubathonPromptRunStatus.Expired; + SubathonEvents.RaisePromptRunUpdate(run, null); + + RecordedRequest request = await handler.WaitForRequestAsync(TestContext.Current.CancellationToken, + r => r.Method == HttpMethod.Post); + await Task.Delay(100, TestContext.Current.CancellationToken); + + JsonObject identifiers = JsonNode.Parse(request.Body)!["SpecialIdentifiers"]!.AsObject(); + Assert.Equal("Expired", identifiers[Id("promptstatus")]!.GetValue()); + Assert.Single(handler.Requests, r => r.Method == HttpMethod.Post); + } + finally { + await service.StopAsync(TestContext.Current.CancellationToken); + } + } + + [Theory] + [InlineData(SubathonEventType.ExternalDonation, false, true)] + [InlineData(SubathonEventType.Command, false, false)] + [InlineData(SubathonEventType.Command, true, true)] + public async Task EventProcessed_CommandsOnlyWhenIncluded(SubathonEventType type, bool includeCommands, + bool expectSent) { + var handler = new RecordingHandler(HttpStatusCode.Accepted); + Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.SubathonEvent); + config[(MixItUpService.ConfigSection, MixItUpService.IncludeCommandsKey)] = includeCommands.ToString(); + MixItUpService service = MakeService(handler, config); + await service.StartAsync(TestContext.Current.CancellationToken); + try { + + SubathonEvents.RaiseSubathonEventProcessed(new SubathonEvent { + EventType = type, Command = SubathonCommandType.AddTime, User = "Tester", ProcessedToSubathon = true + }, true); + + if (!expectSent) { + await Task.Delay(200, TestContext.Current.CancellationToken); + Assert.DoesNotContain(handler.Requests, r => r.Method == HttpMethod.Post); + return; + } + + RecordedRequest request = await handler.WaitForRequestAsync(TestContext.Current.CancellationToken, + r => r.Method == HttpMethod.Post); + + JsonObject identifiers = JsonNode.Parse(request.Body)!["SpecialIdentifiers"]!.AsObject(); + Assert.Equal("SubathonEvent", identifiers[Id("trigger")]!.GetValue()); + Assert.Equal(type.ToString(), identifiers[Id("eventtype")]!.GetValue()); + Assert.Equal("Tester", identifiers[Id("user")]!.GetValue()); + } + finally { + await service.StopAsync(TestContext.Current.CancellationToken); + } + } + + private static SubathonData WithMultiplier(Guid id, double multiplier, bool time, bool points) { + return new SubathonData { + Id = id, IsPaused = false, IsLocked = false, + Multiplier = new MultiplierData { + SubathonId = id, Multiplier = multiplier, ApplyToSeconds = time, ApplyToPoints = points, + Duration = TimeSpan.FromMinutes(5), Started = DateTime.Today + } + }; + } + + [Fact] + public async Task DataUpdate_FiresMultiplierStartAndEnd() { + var handler = new RecordingHandler(HttpStatusCode.Accepted); + Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.MultiplierStarted); + config[(MixItUpService.ConfigSection, MixItUpService.CommandConfigKey(MixItUpTrigger.MultiplierEnded))] = + CommandId.ToString(); + MixItUpService service = MakeService(handler, config); + await service.StartAsync(TestContext.Current.CancellationToken); + try { + Guid id = Guid.NewGuid(); + SubathonEvents.RaiseSubathonDataUpdate(WithMultiplier(id, 1, false, false), DateTime.Now); + SubathonEvents.RaiseSubathonDataUpdate(WithMultiplier(id, 2, true, false), DateTime.Now); + SubathonEvents.RaiseSubathonDataUpdate(WithMultiplier(id, 2, true, false), DateTime.Now); + SubathonEvents.RaiseSubathonDataUpdate(new SubathonData { Id = id, IsPaused = false, IsLocked = false }, + DateTime.Now); + SubathonEvents.RaiseSubathonDataUpdate(WithMultiplier(id, 1, true, false), DateTime.Now); + + await handler.WaitForRequestAsync(TestContext.Current.CancellationToken, + r => r.Method == HttpMethod.Post && r.Body.Contains("MultiplierEnded")); + await Task.Delay(100, TestContext.Current.CancellationToken); + + List sent = handler.Requests.Where(r => r.Method == HttpMethod.Post) + .Select(r => JsonNode.Parse(r.Body)!["SpecialIdentifiers"]!.AsObject()).ToList(); + Assert.Equal(2, sent.Count); + + JsonObject started = sent.Single(i => i[Id("trigger")]!.GetValue() == "MultiplierStarted"); + Assert.Equal("2", started[Id("multiplier")]!.GetValue()); + Assert.Equal("True", started[Id("multipliertime")]!.GetValue()); + Assert.Equal("False", started[Id("multiplierpoints")]!.GetValue()); + Assert.Equal("300", started[Id("multiplierduration")]!.GetValue()); + + JsonObject ended = sent.Single(i => i[Id("trigger")]!.GetValue() == "MultiplierEnded"); + Assert.Equal("2", ended[Id("multiplier")]!.GetValue()); + } + finally { + await service.StopAsync(TestContext.Current.CancellationToken); + } + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, true)] + public void MultiplierIdentifiers_SendTimeAndPointsFlags(bool time, bool points) { + Dictionary ids = MixItUpService.MultiplierIdentifiers( + new MixItUpService.MultiplierSnapshot(true, 1.5, time, points, null, null, false)); + + Assert.Equal(time.ToString(), ids[Id("multipliertime")]); + Assert.Equal(points.ToString(), ids[Id("multiplierpoints")]); + Assert.Equal("1.5", ids[Id("multiplier")]); + Assert.Equal("0", ids[Id("multiplierduration")]); + } + + [Fact] + public void SampleIdentifiers_CoverEveryTrigger() { + foreach (MixItUpTrigger trigger in Enum.GetValues()) { + IReadOnlyList names = MixItUpService.IdentifierNames(trigger); + Assert.Contains($"${Id("trigger")}", names); + Assert.True(names.Count > 1, $"{trigger} has no identifiers"); + Assert.All(names, n => Assert.Matches($"^\\${MixItUpService.IdentifierPrefix}[a-z]+$", n)); + } + } + + private sealed record RecordedRequest(HttpMethod Method, string Uri, string Body); + + private sealed class RecordingHandler(HttpStatusCode statusCode, string? body = null, bool throwConnect = false) + : HttpMessageHandler { + private readonly SemaphoreSlim _signal = new(0); + public ConcurrentQueue Requests { get; } = new(); + + public async Task WaitForRequestAsync(CancellationToken ct, + Func? match = null) { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + while (true) { + RecordedRequest? found = Requests.FirstOrDefault(r => match?.Invoke(r) ?? true); + if (found != null) return found; + await _signal.WaitAsync(timeout.Token); + } + } + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken ct) { + if (throwConnect) throw new HttpRequestException("Connection refused"); + string content = request.Content == null ? "" : await request.Content.ReadAsStringAsync(ct); + Requests.Enqueue(new RecordedRequest(request.Method, request.RequestUri!.ToString(), content)); + _signal.Release(); + + var response = new HttpResponseMessage(statusCode); + if (body != null) response.Content = new StringContent(body, Encoding.UTF8, "application/json"); + return response; + } + } +} diff --git a/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs index f84b7ae5..a17f046b 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs @@ -463,6 +463,26 @@ public void ProcessData_ContributionPurchased_DifferentCurrency_UsesCorrectCurre Assert.Equal("EUR", ev.Currency); } + [Theory] + [InlineData("", "USD")] + [InlineData(" ", "USD")] + [InlineData("eur", "EUR")] + public void ProcessData_MoneyEvents_BlankCurrencyFallsBackAndIsUppercased(string sent, string expected) { + (ThroneService service, _) = MakeService(); + + string[] payloads = [ + BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Mug", 2500, sent), + BuildContributionPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Campaign", 5000, sent), + BuildCrowdfundedJson(Guid.NewGuid().ToString(), "Desk", 10000, sent) + ]; + + foreach (string json in payloads) { + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); + Assert.NotNull(ev); + Assert.Equal(expected, ev.Currency); + } + } + [Fact] public void ProcessData_ContributionPurchased_IsSim_SetsSimulatedSource() { (ThroneService service, _) = MakeService(); diff --git a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs index b507d9a1..4106ca1b 100644 --- a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs @@ -1,4 +1,5 @@ -using System.Net; +using System.Globalization; +using System.Net; using System.Reflection; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -1227,25 +1228,132 @@ public async Task ProcessSubathonEvent_OrderCommission_MoneySum_Branches( await conn.CloseAsync(); } + [Theory] + [InlineData("en-US", "25.50")] + [InlineData("de-DE", "25.50")] + [InlineData("de-DE", "25,50")] + [InlineData("fr-FR", "25.50")] + public async Task ProcessSubathonEvent_CurrencyDonation_AddsMoney_InAnyLocale(string culture, string value) { + CultureInfo previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo(culture); + try { + (EventService service, DbContextOptions options, SqliteConnection conn) = + await SetupServiceWithDb(0, false); + + var ev = new SubathonEvent { + Id = Guid.NewGuid(), + EventType = SubathonEventType.KoFiDonation, + Currency = "USD", + Value = value + }; + + (bool processed, _) = await service.ProcessSubathonEvent(ev); + Assert.True(processed); + + await using var db = new AppDbContext(options); + SubathonData sub = await db.SubathonDatas.FirstAsync(TestContext.Current.CancellationToken); + Assert.Equal(25.5, sub.MoneySum!.Value, 2); + + await service.StopAsync(TestContext.Current.CancellationToken); + await conn.CloseAsync(); + } + finally { + CultureInfo.CurrentCulture = previous; + } + } + [Fact] - public async Task ProcessSubathonEvent_CurrencyDonation_AddsMoney() { + public async Task EventLoop_KeepsProcessingAfterAnEventThrows() { (EventService service, DbContextOptions options, SqliteConnection conn) = await SetupServiceWithDb(0, false); - var ev = new SubathonEvent { - Id = Guid.NewGuid(), - EventType = SubathonEventType.KoFiDonation, - Currency = "USD", - Value = "25.00" + var good = new SubathonEvent { + Id = Guid.NewGuid(), EventType = SubathonEventType.KoFiDonation, Currency = "USD", Value = "5.00", + EventTimestamp = DateTime.Now.AddSeconds(1) + }; + var goodDone = new TaskCompletionSource(); + SubathonEvents.SubathonEventProcessed += (ev, _) => { + if (ev.Id == good.Id) goodDone.TrySetResult(true); }; - (bool processed, _) = await service.ProcessSubathonEvent(ev); - Assert.True(processed); + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { + Id = Guid.NewGuid(), EventType = SubathonEventType.KoFiDonation, Currency = "USD", Value = null!, + EventTimestamp = DateTime.Now + }); + SubathonEvents.RaiseSubathonEventCreated(good); + + await Task.WhenAny(goodDone.Task, Task.Delay(5000, TestContext.Current.CancellationToken)); + Assert.True(goodDone.Task.IsCompleted, "Event after the failing one was never processed"); await using var db = new AppDbContext(options); SubathonData sub = await db.SubathonDatas.FirstAsync(TestContext.Current.CancellationToken); - Assert.True(sub.MoneySum > 0); - Assert.Equal(25.0, sub.MoneySum!.Value, 2); + Assert.Equal(5.0, sub.MoneySum!.Value, 2); + + await service.StopAsync(TestContext.Current.CancellationToken); + await conn.CloseAsync(); + } + + [Fact] + public async Task DonationCrossingGoal_RaisesGoalCompletedOnce() { + (EventService service, DbContextOptions options, SqliteConnection conn) = + await SetupServiceWithDb(0, false); + await using (var db = new AppDbContext(options)) { + SubathonGoalSet set = await db.SubathonGoalSets.FirstAsync(TestContext.Current.CancellationToken); + set.Type = GoalsType.Money; + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var completions = 0; + SubathonEvents.SubathonGoalCompleted += (_, _) => Interlocked.Increment(ref completions); + + (bool processed, _) = await service.ProcessSubathonEvent(new SubathonEvent { + Id = Guid.NewGuid(), EventType = SubathonEventType.KoFiDonation, Currency = "USD", Value = "25.00" + }); + + Assert.True(processed); + Assert.Equal(1, completions); + + await service.StopAsync(TestContext.Current.CancellationToken); + await conn.CloseAsync(); + } + + [Fact] + public async Task CancelledOrder_RemovesTrackedEventAndWarns() { + (EventService service, DbContextOptions options, SqliteConnection conn) = + await SetupServiceWithDb(0, false); + + var order = new SubathonEvent { + Id = Guid.NewGuid(), EventType = SubathonEventType.FourthWallOrder, Source = SubathonEventSource.FourthWall, + User = "Buyer", Currency = "USD", Value = "25.00", Amount = 1, SecondaryValue = "10.00|USD" + }; + (bool processed, _) = await service.ProcessSubathonEvent(order); + Assert.True(processed); + + var deleted = new TaskCompletionSource(); + var warned = false; + void OnDeleted(List evs) { + if (evs.Any(e => e.Id == order.Id)) deleted.TrySetResult(true); + } + void OnError(string level, string source, string message, DateTime _) { + if (level == "WARN" && message.Contains("#1234")) warned = true; + } + SubathonEvents.SubathonEventsDeleted += OnDeleted; + ErrorMessageEvents.ErrorEventOccured += OnError; + + SubathonEvents.RaiseSubathonEventCancelled(order.Id, SubathonEventType.FourthWallOrder, "#1234"); + await Task.WhenAny(deleted.Task, Task.Delay(5000, TestContext.Current.CancellationToken)); + + SubathonEvents.SubathonEventsDeleted -= OnDeleted; + ErrorMessageEvents.ErrorEventOccured -= OnError; + + Assert.True(deleted.Task.IsCompleted, "Cancelled order's event was never removed"); + Assert.True(warned); + await using (var db = new AppDbContext(options)) { + Assert.False(await db.SubathonEvents.AnyAsync(e => e.Id == order.Id, + TestContext.Current.CancellationToken)); + } + + Assert.False(await service.DeleteCancelledEventAsync(Guid.NewGuid(), SubathonEventType.FourthWallOrder, "#9")); await service.StopAsync(TestContext.Current.CancellationToken); await conn.CloseAsync(); diff --git a/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs index 07fc0b2b..63254333 100644 --- a/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs @@ -407,6 +407,44 @@ public async Task ProcessTriggers_AccumulatesSpinsOverMultipleFires() { await conn.CloseAsync(); } + [Fact] + public async Task ProcessTriggers_ConcurrentFires_DoNotLoseSpins() { + (WheelSpinTriggerService service, DbContextOptions options, SqliteConnection conn) = + await SetupServiceWithDb(); + await using (var db = new AppDbContext(options)) { + db.WheelSpinTriggers.Add(new WheelSpinTrigger + { EventType = SubathonEventType.TwitchSub, IsEnabled = true, SpinsToAdd = 2 }); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + const int fires = 10; + var fired = 0; + var allFired = new TaskCompletionSource(); + WheelEvents.WheelSpinTriggerFired += (_, _, _) => { + if (Interlocked.Increment(ref fired) == fires) allFired.TrySetResult(true); + }; + + for (var i = 0; i < fires; i++) + SubathonEvents.RaiseSubathonEventProcessed(new SubathonEvent { + Id = Guid.NewGuid(), EventType = SubathonEventType.TwitchSub, + Command = SubathonCommandType.None, Value = "1000", User = $"User{i}" + }, true); + + await Task.WhenAny(allFired.Task, Task.Delay(5000, TestContext.Current.CancellationToken)); + Assert.True(allFired.Task.IsCompleted, $"Only {fired}/{fires} triggers fired"); + + await using var checkDb = new AppDbContext(options); + Assert.Equal(fires * 2, StateValueHelper.Get(checkDb, StateKeys.WheelSpinsOwed)); + + var factory = new Mock>(); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new AppDbContext(options)); + Assert.Equal(0, await StateValueHelper.AddIntAsync(factory.Object, StateKeys.WheelSpinsOwed, -(fires * 2 + 5))); + + await service.StopAsync(TestContext.Current.CancellationToken); + await conn.CloseAsync(); + } + [Fact] public async Task GiftSub_NoCountThreshold_ReturnsSpinsToAddFlat() { (WheelSpinTriggerService service, DbContextOptions options, SqliteConnection conn) = diff --git a/SubathonManager.UI/App.Subathon.cs b/SubathonManager.UI/App.Subathon.cs index ca32c1b3..bcb891a7 100644 --- a/SubathonManager.UI/App.Subathon.cs +++ b/SubathonManager.UI/App.Subathon.cs @@ -81,9 +81,9 @@ await db.Database.ExecuteSqlRawAsync( snapshot.IsLocked = true; } else if (snapshot.CapDateTime != null && DateTime.Now >= snapshot.CapDateTime && - snapshot is { IsPaused: false }) { + (snapshot is { IsPaused: false } or { IsLocked: false })) { await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET IsLocked = 1 WHERE IsActive = 1 AND IsPaused = 1 AND Id = {0}", + "UPDATE SubathonDatas SET IsLocked = 1, IsPaused = 1 WHERE IsActive = 1 AND Id = {0} AND (IsLocked = 0 OR IsPaused = 0)", snapshot.Id); snapshot.IsLocked = true; snapshot.IsPaused = true; diff --git a/SubathonManager.UI/App.axaml.cs b/SubathonManager.UI/App.axaml.cs index ec1c5976..f951a45d 100644 --- a/SubathonManager.UI/App.axaml.cs +++ b/SubathonManager.UI/App.axaml.cs @@ -155,8 +155,13 @@ public override void OnFrameworkInitializationCompleted() { await AppDbContext.PauseAllTimers(context1); await using AppDbContext context2 = await _factory.CreateDbContextAsync(); await AppDbContext.ResetPowerHour(context2); - await using AppDbContext context3 = await _factory.CreateDbContextAsync(); - await SetupSubathonCurrencyData(context3, false); + try { + await using AppDbContext context3 = await _factory.CreateDbContextAsync(); + await SetupSubathonCurrencyData(context3, false); + } + catch (Exception ex) { + _logger?.LogError(ex, "Failed to recalculate subathon currency data on startup"); + } await sm.StartAsync(fireAndForget: true); await sm.StartAsync(fireAndForget: true); @@ -480,11 +485,14 @@ private async Task SetupSubathonCurrencyData(AppDbContext db, bool? optionToggle string value = ev.Value; string? curr = ev.Currency; if (ev.EventType.IsOrder()) { - value = ev.SecondaryValue.Split('|')[0]; - curr = ev.SecondaryValue.Split('|')[1]; + string[] parts = ev.SecondaryValue.Split('|'); + if (parts.Length < 2 || !Utils.TryParseAmount(parts[0], out _)) continue; + value = parts[0]; + curr = parts[1]; + if (!currencyService.IsValidCurrency(curr)) continue; } - double amt = await currencyService.ConvertAsync(double.Parse(value), curr, currency.ToUpper()); + double amt = await currencyService.ConvertAsync(Utils.ParseAmount(value), curr, currency.ToUpper()); sum += amt; } diff --git a/SubathonManager.UI/Controls/FilterOption.cs b/SubathonManager.UI/Controls/FilterOption.cs index 5f15aa62..9fc35c8e 100644 --- a/SubathonManager.UI/Controls/FilterOption.cs +++ b/SubathonManager.UI/Controls/FilterOption.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using SubathonManager.Core.Enums; namespace SubathonManager.UI.Controls; @@ -10,6 +11,19 @@ public sealed class FilterOption : INotifyPropertyChanged { public string Value { get; init; } = ""; public string Group { get; init; } = ""; + public static List EventTypes(bool includeCommands = false) { + return Enum.GetValues() + .Where(t => t != SubathonEventType.Unknown && (includeCommands || t != SubathonEventType.Command)) + .OrderBy(t => SubathonEventSourceHelper.GetSourceOrder(((SubathonEventType?)t).GetSource())) + .ThenBy(t => ((SubathonEventType?)t).GetLabel(), StringComparer.OrdinalIgnoreCase) + .Select(t => new FilterOption { + Label = ((SubathonEventType?)t).GetLabel(), + Value = t.ToString(), + Group = ((SubathonEventType?)t).GetSource().ToString() + }) + .ToList(); + } + public bool Selected { get => _selected; set { diff --git a/SubathonManager.UI/Controls/FilterPopout.axaml.cs b/SubathonManager.UI/Controls/FilterPopout.axaml.cs index d6be8afb..eee44e6f 100644 --- a/SubathonManager.UI/Controls/FilterPopout.axaml.cs +++ b/SubathonManager.UI/Controls/FilterPopout.axaml.cs @@ -11,10 +11,18 @@ public FilterPopout() { InitializeComponent(); GroupList.ItemsSource = _groups; UpdateSummary(); + ChoicesPopup.Closed += (_, _) => Closed?.Invoke(this, EventArgs.Empty); } public string EmptyText { get; set; } = "All (no filter)"; + public bool ShowSummary { + get => SummaryBox.IsVisible; + set => SummaryBox.IsVisible = value; + } + + public event EventHandler? Closed; + public IReadOnlyList Options => _options; public IEnumerable SelectedOptions => _options.Where(o => o.Selected); @@ -66,12 +74,18 @@ private void UpdateSummary() { CountText.Text = $"{selected.Count} of {_options.Count} selected"; } - private void Open_Click(object? sender, RoutedEventArgs e) { + public void Open(Control? anchor = null) { + ChoicesPopup.PlacementTarget = anchor ?? SummaryBox; + ChoicesPopup.Placement = anchor == null ? PlacementMode.Bottom : PlacementMode.BottomEdgeAlignedRight; SearchBox.Text = string.Empty; ApplySearch(string.Empty); ChoicesPopup.IsOpen = true; } + private void Open_Click(object? sender, RoutedEventArgs e) { + Open(); + } + private void Close_Click(object? sender, RoutedEventArgs e) { ChoicesPopup.IsOpen = false; } diff --git a/SubathonManager.UI/EditRouteWindow.Handlers.cs b/SubathonManager.UI/EditRouteWindow.Handlers.cs index 8b0fc1d6..a651cf41 100644 --- a/SubathonManager.UI/EditRouteWindow.Handlers.cs +++ b/SubathonManager.UI/EditRouteWindow.Handlers.cs @@ -31,7 +31,7 @@ namespace SubathonManager.UI; public partial class EditRouteWindow { private MenuFlyout? _statusFlyout; - + #region GeneralHandlers private void WidgetDirtyBorder_Loaded(object? sender, RoutedEventArgs e) { @@ -148,11 +148,11 @@ private async void CopyOverlayUrl_Click(object? sender, RoutedEventArgs e) { _logger?.LogError(ex, "Failed to copy overlay URL"); } } - + private void ShowObsCanvasSelection_Click(object? sender, RoutedEventArgs e) { var flyout = new MenuFlyout { Placement = PlacementMode.Bottom }; - - FillObsCanvases(flyout.Items);/////// + + FillObsCanvases(flyout.Items); /////// flyout.Closed += (_, _) => { if (ReferenceEquals(_statusFlyout, flyout)) _statusFlyout = null; }; @@ -162,12 +162,12 @@ private void ShowObsCanvasSelection_Click(object? sender, RoutedEventArgs e) { private void FillObsCanvases(ItemCollection items) { items.Clear(); - var canvases = ServiceManager.OBS.GetCanvases(); + Dictionary> canvases = ServiceManager.OBS.GetCanvases(); if (canvases.Count == 0) return; - - foreach (var canvasData in canvases) { - var width = canvasData.Value["Width"]; - var height = canvasData.Value["Height"]; + + foreach (KeyValuePair> canvasData in canvases) { + int width = canvasData.Value["Width"]; + int height = canvasData.Value["Height"]; var name = $"{canvasData.Key} (WxH {width}x{height})"; var groupItem = new MenuItem { Header = name @@ -934,6 +934,73 @@ private async void MoveDown_Click(object? sender, RoutedEventArgs e) { } } + private void WidgetGrip_PointerPressed(object? sender, PointerPressedEventArgs e) { + if (sender is not Control { Tag: Widget w } grip || !e.GetCurrentPoint(grip).Properties.IsLeftButtonPressed) + return; + _dragWidget = w; + _dragTargetIndex = _widgets.IndexOf(w); + WidgetsList.ContainerFromItem(w)?.SetValue(OpacityProperty, 0.6); + e.Pointer.Capture(grip); + e.Handled = true; + } + + private void WidgetGrip_PointerMoved(object? sender, PointerEventArgs e) { + if (_dragWidget == null) return; + int from = _widgets.IndexOf(_dragWidget); + if (from < 0) return; + + double viewY = e.GetPosition(WidgetsScroll).Y; + if (viewY < 24) WidgetsScroll.Offset = WidgetsScroll.Offset.WithY(Math.Max(0, WidgetsScroll.Offset.Y - 12)); + else if (viewY > WidgetsScroll.Bounds.Height - 24) + WidgetsScroll.Offset = WidgetsScroll.Offset.WithY(WidgetsScroll.Offset.Y + 12); + + double y = e.GetPosition(WidgetsList).Y; + int target = from; + Rect? targetBounds = null; + for (var i = 0; i < _widgets.Count; i++) { + if (WidgetsList.ContainerFromIndex(i) is not { } c) continue; + Rect b = c.Bounds; + if ((i == 0 && y < b.Top) || (y >= b.Top && y <= b.Bottom) || (i == _widgets.Count - 1 && y > b.Bottom)) { + target = i; + targetBounds = b; + break; + } + } + + if (targetBounds is not { } tb) return; + _dragTargetIndex = target; + WidgetDropLine.IsVisible = target != from; + WidgetDropLine.Margin = new Thickness(0, target < from ? Math.Max(0, tb.Top - 5) : tb.Bottom - 6, 0, 0); + } + + private async void WidgetGrip_PointerReleased(object? sender, PointerReleasedEventArgs e) { + e.Pointer.Capture(null); + await FinishWidgetDragAsync(); + } + + private async void WidgetGrip_PointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) { + await FinishWidgetDragAsync(); + } + + private async Task FinishWidgetDragAsync() { + if (_dragWidget == null) return; + Widget moving = _dragWidget; + int to = _dragTargetIndex; + _dragWidget = null; + _dragTargetIndex = -1; + WidgetDropLine.IsVisible = false; + WidgetsList.ContainerFromItem(moving)?.SetValue(OpacityProperty, 1.0); + + int from = _widgets.IndexOf(moving); + if (from < 0 || to < 0 || to >= _widgets.Count || from == to) return; + try { + await MoveWidgetZAsync(from, to); + } + catch (Exception ex) { + _logger?.LogError(ex, "Failed to reorder widget Z-Index"); + } + } + private async void SaveRouteButton_Click(object? sender, RoutedEventArgs e) { if (_route == null) return; await SaveCurrentRoute(); @@ -1232,7 +1299,7 @@ private static void SetJsEventTypeButtonLabel(Button btn, string? value) { private static string JsEventTypeDisplay(string? value) { if (string.IsNullOrWhiteSpace(value)) return "- none -"; - if (Enum.TryParse(value, out SubathonEventType et)) + if (Enum.TryParse(value, out SubathonEventType et)) return $"{et.GetSource()} - {et.GetLabel()}"; GoAffProStore? store = GoAffProStoreRegistry.All().FirstOrDefault(s => s.InternalEventName == value); return store != null ? $"{SubathonEventSource.GoAffPro} - {store.EventName}" : value; diff --git a/SubathonManager.UI/EditRouteWindow.axaml b/SubathonManager.UI/EditRouteWindow.axaml index cafaa09b..a725c437 100644 --- a/SubathonManager.UI/EditRouteWindow.axaml +++ b/SubathonManager.UI/EditRouteWindow.axaml @@ -421,7 +421,11 @@ - + + + @@ -458,8 +462,18 @@ BorderBrush="Transparent" Padding="10" Tag="{Binding Id}" Loaded="WidgetDirtyBorder_Loaded" Unloaded="WidgetDirtyBorder_Unloaded"> - - + + + + + - - + @@ -503,6 +516,9 @@ + + + diff --git a/SubathonManager.UI/MainWindow.axaml.cs b/SubathonManager.UI/MainWindow.axaml.cs index e3c45a2c..f4fe58a7 100644 --- a/SubathonManager.UI/MainWindow.axaml.cs +++ b/SubathonManager.UI/MainWindow.axaml.cs @@ -27,6 +27,14 @@ public MainWindow() { InitHome(); InitOverlays(); + RecentEventsList.HiddenTypesChanged += UpdateRecentEventsFilterTip; + UpdateRecentEventsFilterTip(); + + HomeScheduleList.ItemRequested += (date, id) => { + MainWindowTabs.SelectedItem = ScheduleTabItem; + SchedulePage.ShowItem(date, id); + }; + Loaded += async (_, _) => { await MaybeShowTelemetryPromptAsync(); await ImportPendingOverlayAsync(); @@ -34,6 +42,23 @@ public MainWindow() { }; } + private void HomeListTabs_SelectionChanged(object? sender, SelectionChangedEventArgs e) { + if (!ReferenceEquals(e.Source, HomeListTabs)) return; + RecentEventsFilterBtn.IsVisible = ReferenceEquals(HomeListTabs.SelectedItem, RecentEventsTab); + } + + private void RecentEventsFilterBtn_Click(object? sender, RoutedEventArgs e) { + RecentEventsList.OpenTypeFilter(RecentEventsFilterBtn); + } + + private void UpdateRecentEventsFilterTip() { + int hidden = RecentEventsList.HiddenTypeCount; + ToolTip.SetTip(RecentEventsFilterBtn, hidden == 0 + ? "Choose which event types show here" + : $"Choose which event types show here ({hidden} hidden)"); + RecentEventsFilterBtn.Opacity = hidden == 0 ? 0.7 : 1; + } + private async void CopyVersion_Click(object? sender, RoutedEventArgs e) { if (string.IsNullOrEmpty(_fullVersion)) return; await UiHelpers.TrySetClipboardTextAsync(_fullVersion); diff --git a/SubathonManager.UI/Services/ServiceManager.cs b/SubathonManager.UI/Services/ServiceManager.cs index 1db49614..15843f04 100644 --- a/SubathonManager.UI/Services/ServiceManager.cs +++ b/SubathonManager.UI/Services/ServiceManager.cs @@ -68,6 +68,7 @@ private static IServiceProvider Provider { public static TreatStreamService TreatStream => Provider.GetRequiredService(); public static OBSService OBS => Provider.GetRequiredService(); public static VTSService VTubeStudio => Provider.GetRequiredService(); + public static MixItUpService MixItUp => Provider.GetRequiredService(); public static WebServer Server => Provider.GetRequiredService(); @@ -92,6 +93,7 @@ public async Task StartIntegrationsAsync() { await StartAsync(); await StartAsync(); await StartAsync(); + await StartAsync(); await StartAsync(); await StartAsync(); } @@ -112,6 +114,7 @@ public async Task StopIntegrationsAsync() { await StopAsync(); await StopAsync(); await StopAsync(); + await StopAsync(); await StopAsync(); await StopAsync(); await StopAsync(); diff --git a/SubathonManager.UI/Services/ServiceRegistration.cs b/SubathonManager.UI/Services/ServiceRegistration.cs index 7280bf15..dcc77d56 100644 --- a/SubathonManager.UI/Services/ServiceRegistration.cs +++ b/SubathonManager.UI/Services/ServiceRegistration.cs @@ -92,6 +92,8 @@ public static void AddIntegrations(this IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddHttpClient(nameof(MixItUpService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); + services.AddSingleton(); } private static void ConfigureLogging(ILoggingBuilder builder) { diff --git a/SubathonManager.UI/SubathonManager.UI.csproj b/SubathonManager.UI/SubathonManager.UI.csproj index b3499b41..69a92239 100644 --- a/SubathonManager.UI/SubathonManager.UI.csproj +++ b/SubathonManager.UI/SubathonManager.UI.csproj @@ -63,4 +63,13 @@ + + + Code + + + Code + + + diff --git a/SubathonManager.UI/Views/EventListView.axaml b/SubathonManager.UI/Views/EventListView.axaml index 8587f2bd..95628e1c 100644 --- a/SubathonManager.UI/Views/EventListView.axaml +++ b/SubathonManager.UI/Views/EventListView.axaml @@ -48,6 +48,8 @@ + diff --git a/SubathonManager.UI/Views/EventListView.axaml.cs b/SubathonManager.UI/Views/EventListView.axaml.cs index f595a85c..7f21fe58 100644 --- a/SubathonManager.UI/Views/EventListView.axaml.cs +++ b/SubathonManager.UI/Views/EventListView.axaml.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Numerics; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Threading; @@ -10,6 +11,7 @@ using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; using SubathonManager.Data; +using SubathonManager.UI.Controls; using SubathonManager.UI.Services; namespace SubathonManager.UI.Views; @@ -19,11 +21,17 @@ public partial class EventListView : UserControl { private readonly IDbContextFactory _factory; private readonly int _maxItems = 20; + private HashSet _hiddenTypes = []; + public EventListView() { _factory = AppServices.Provider.GetRequiredService>(); InitializeComponent(); EventListPanel.ItemsSource = EventItems; _config = AppServices.Provider.GetRequiredService(); + + LoadHiddenTypes(); + TypeFilter.SetOptions(FilterOption.EventTypes(true)); + TypeFilter.Closed += (_, _) => SaveHiddenTypesFromPicker(); Task.Run(async () => await LoadRecentEvents()); SubathonEvents.SubathonEventProcessed += OnSubathonEventProcessed; @@ -33,6 +41,40 @@ public EventListView() { public ObservableCollection EventItems { get; set; } = new(); + public int HiddenTypeCount => _hiddenTypes.Count; + + public void OpenTypeFilter(Control anchor) { + TypeFilter.SetSelected(Enum.GetValues() + .Where(t => !_hiddenTypes.Contains(t)) + .Select(t => t.ToString())); + TypeFilter.Open(anchor); + } + + public event Action? HiddenTypesChanged; + + private void LoadHiddenTypes() { + using AppDbContext db = _factory.CreateDbContext(); + string raw = StateValueHelper.Get(db, StateKeys.RecentEventsHiddenTypes); + if (!BigInteger.TryParse(raw, out BigInteger mask) || mask.IsZero) return; + _hiddenTypes = Enum.GetValues() + .Where(t => !((mask >> (int)t) & BigInteger.One).IsZero) + .ToHashSet(); + } + + private void SaveHiddenTypesFromPicker() { + HashSet hidden = TypeFilter.Options + .Where(o => !o.Selected) + .Select(o => Enum.Parse(o.Value)) + .ToHashSet(); + if (hidden.SetEquals(_hiddenTypes)) return; + + _hiddenTypes = hidden; + BigInteger mask = hidden.Aggregate(BigInteger.Zero, (m, t) => m | (BigInteger.One << (int)t)); + _ = StateValueHelper.SetAsync(_factory, StateKeys.RecentEventsHiddenTypes, mask.ToString()); + HiddenTypesChanged?.Invoke(); + Task.Run(async () => await LoadRecentEvents()); + } + private void OnSubathonEventsDeleted(List events) { Task.Run(async () => await LoadRecentEvents()); } @@ -49,6 +91,7 @@ private async void OnSubathonEventProcessed(SubathonEvent subathonEvent, bool wa && subathonEvent.EventType != SubathonEventType.Command && subathonEvent.EventType != SubathonEventType.DonationAdjustment && subathonEvent.EventType != SubathonEventType.TwitchHypeTrain) return; + if (subathonEvent.EventType is { } type && _hiddenTypes.Contains(type)) return; await Dispatcher.UIThread.InvokeAsync(() => { SubathonEvent? existing = EventItems.FirstOrDefault(x => x.Id == subathonEvent.Id); @@ -66,8 +109,10 @@ private async Task LoadRecentEvents() { await using AppDbContext db = await _factory.CreateDbContextAsync(); SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); List events = new(); + List hidden = _hiddenTypes.Select(t => (SubathonEventType?)t).ToList(); if (subathon != null) events = await db.SubathonEvents.Where(ev => ev.SubathonId == subathon.Id + && !hidden.Contains(ev.EventType) && (showOverride || ev.SecondsValue > 0 || ev.PointsValue >= 1 || ev.Command != SubathonCommandType.None || ev.EventType == SubathonEventType.TwitchHypeTrain diff --git a/SubathonManager.UI/Views/Schedule/ScheduleUpcomingView.axaml b/SubathonManager.UI/Views/Schedule/ScheduleUpcomingView.axaml new file mode 100644 index 00000000..f594edba --- /dev/null +++ b/SubathonManager.UI/Views/Schedule/ScheduleUpcomingView.axaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SubathonManager.UI/Views/Schedule/ScheduleUpcomingView.axaml.cs b/SubathonManager.UI/Views/Schedule/ScheduleUpcomingView.axaml.cs new file mode 100644 index 00000000..d34eab48 --- /dev/null +++ b/SubathonManager.UI/Views/Schedule/ScheduleUpcomingView.axaml.cs @@ -0,0 +1,151 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Models; +using SubathonManager.Data; +using SubathonManager.UI.Controls; + +namespace SubathonManager.UI.Views.Schedule; + +public partial class ScheduleUpcomingView : UserControl { + private const int Limit = 30; + + private readonly IDbContextFactory _factory; + + public ScheduleUpcomingView() { + _factory = AppServices.Provider.GetRequiredService>(); + InitializeComponent(); + + ScheduleEvents.ScheduleChanged += source => { + if (ReferenceEquals(source, this)) return; + Dispatcher.UIThread.Post(Refresh); + }; + Loaded += (_, _) => Refresh(); + } + + public event Action? ItemRequested; + + private void Refresh() { + DateTime today = DateTime.Today; + List items; + using (AppDbContext db = _factory.CreateDbContext()) { + items = db.ScheduleItems.AsNoTracking() + .Where(i => i.Date >= today && !i.IsDone) + .OrderBy(i => i.Date).ThenBy(i => i.SortOrder).ThenBy(i => i.CreatedAt) + .Take(Limit) + .ToList(); + } + + CardsStack.Children.Clear(); + foreach (ScheduleItem item in items) + CardsStack.Children.Add(BuildCard(item, today)); + EmptyText.IsVisible = items.Count == 0; + } + + private Border BuildCard(ScheduleItem item, DateTime today) { + string when = item.Date == today ? "Today" + : item.Date == today.AddDays(1) ? "Tomorrow" + : item.Date.ToString("ddd, MMM d", CultureInfo.CurrentCulture); + bool isEvent = item.Kind == ScheduleItemKind.Event; + + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto") }; + + var icon = new SymIcon { + Glyph = isEvent ? "CalendarToday20" : "TaskListSquare20", + Opacity = 0.8, + Margin = new Thickness(0, 0, 10, 0), + VerticalAlignment = VerticalAlignment.Center + }; + ToolTip.SetTip(icon, isEvent ? "Event" : "Task"); + + var text = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + text.Children.Add(new TextBlock { + Text = string.IsNullOrWhiteSpace(item.Title) ? "(untitled)" : item.Title, + FontSize = 15, + FontWeight = FontWeight.Bold, + TextTrimming = TextTrimming.CharacterEllipsis + }); + + var meta = new TextBlock { + Text = $"{when} · {item.TimeLabel()}", + FontSize = 12, + Margin = new Thickness(0, 2, 0, 0) + }; + + meta.Classes.Add("soft"); + text.Children.Add(meta); + + string firstLine = item.Description.Split('\n', 2)[0].Trim(); + if (firstLine.Length > 0) { + var desc = new TextBlock { + Text = firstLine, + FontSize = 12, + Opacity = 0.8, + Margin = new Thickness(0, 2, 0, 0), + TextTrimming = TextTrimming.CharacterEllipsis + }; + desc.Classes.Add("soft"); + text.Children.Add(desc); + } + + var doneBtn = new Button { + Content = new SymIcon { Glyph = "CheckmarkCircle20" }, + Width = 34, + Height = 34, + Padding = new Thickness(0), + Margin = new Thickness(8, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + + doneBtn.Classes.Add("donebtn"); + ToolTip.SetTip(doneBtn, "Mark as done"); + doneBtn.Click += (_, _) => MarkDone(item.Id); + + Grid.SetColumn(icon, 0); + Grid.SetColumn(text, 1); + Grid.SetColumn(doneBtn, 2); + grid.Children.Add(icon); + grid.Children.Add(text); + grid.Children.Add(doneBtn); + + var card = new Border { Child = grid }; + card.Classes.Add("upcard"); + ToolTip.SetTip(text, "Open in Schedule"); + card.Tapped += (_, e) => { + if (IsWithinButton(e.Source)) return; + ItemRequested?.Invoke(item.Date, item.Id); + }; + return card; + } + + private static bool IsWithinButton(object? source) { + var v = source as Visual; + while (v != null) { + if (v is Button) return true; + v = v.GetVisualParent(); + } + + return false; + } + + private void MarkDone(Guid id) { + using (AppDbContext db = _factory.CreateDbContext()) { + db.ScheduleItems.Where(i => i.Id == id) + .ExecuteUpdate(s => s.SetProperty(i => i.IsDone, true)); + } + + Refresh(); + ScheduleEvents.RaiseScheduleChanged(this); + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/Schedule/ScheduleView.Bulk.cs b/SubathonManager.UI/Views/Schedule/ScheduleView.Bulk.cs new file mode 100644 index 00000000..5b5fcc1c --- /dev/null +++ b/SubathonManager.UI/Views/Schedule/ScheduleView.Bulk.cs @@ -0,0 +1,153 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Media; +using FluentAvalonia.UI.Controls; +using Microsoft.EntityFrameworkCore; +using SubathonManager.Core.Models; +using SubathonManager.Data; + +namespace SubathonManager.UI.Views.Schedule; + +public partial class ScheduleView { + private async void BulkDelete_Click(object? sender, RoutedEventArgs e) { + CommitEditorIfDirty(); + + int completed, total; + await using (AppDbContext db = await _factory.CreateDbContextAsync()) { + total = await db.ScheduleItems.CountAsync(); + completed = await db.ScheduleItems.CountAsync(i => i.IsDone); + } + + if (total == 0) { + await ShowMessageAsync("Delete Schedule Items", "There is nothing in the schedule to delete"); + return; + } + + (BulkDeleteMode mode, DateTime before)? choice = await AskBulkDeleteAsync(completed, total); + if (choice == null) return; + (BulkDeleteMode mode, DateTime cutoff) = choice.Value; + + if (mode == BulkDeleteMode.Everything && !await ConfirmDeleteEverythingAsync(total)) return; + + int deleted; + await using (AppDbContext db = await _factory.CreateDbContextAsync()) { + IQueryable query = FilterForBulkDelete(db.ScheduleItems, mode, cutoff); + deleted = await query.ExecuteDeleteAsync(); + } + + CloseEditor(); + LoadDay(); + RenderCalendar(); + RenderUpcoming(); + NotifyScheduleChanged(); + + await ShowMessageAsync("Delete Schedule Items", + deleted == 0 + ? "Nothing matched, no items were deleted" + : $"Deleted {deleted} item{(deleted == 1 ? "" : "s")}"); + } + + private static IQueryable FilterForBulkDelete(IQueryable items, + BulkDeleteMode mode, DateTime cutoff) { + return mode switch { + BulkDeleteMode.Completed => items.Where(i => i.IsDone), + BulkDeleteMode.Before => items.Where(i => i.Date < cutoff), + _ => items + }; + } + + private async Task<(BulkDeleteMode, DateTime)?> AskBulkDeleteAsync(int completed, int total) { + const string group = "ScheduleBulkDelete"; + var completedRadio = new RadioButton { + GroupName = group, + Content = $"All completed items ({completed})", + IsChecked = true + }; + var beforeRadio = new RadioButton { + GroupName = group, + Content = "Everything before a date", + Margin = new Thickness(0, 6, 0, 0) + }; + var beforePicker = new DatePicker { SelectedDate = new DateTimeOffset(DateTime.Today) }; + var beforeCount = new TextBlock { FontSize = 12, Opacity = 0.7 }; + var beforePanel = new StackPanel { Margin = new Thickness(28, 8, 0, 0), IsEnabled = false, Spacing = 6 }; + beforePanel.Children.Add(Labelled("Before (the day itself is kept)", beforePicker)); + beforePanel.Children.Add(beforeCount); + + var everythingRadio = new RadioButton { + GroupName = group, + Content = $"Everything ({total})", + Margin = new Thickness(0, 10, 0, 0) + }; + + async void UpdateBeforeCount() { + if (beforePicker.SelectedDate is not { } picked) { + beforeCount.Text = ""; + return; + } + + DateTime cutoff = picked.Date; + await using AppDbContext db = await _factory.CreateDbContextAsync(); + int count = await db.ScheduleItems.CountAsync(i => i.Date < cutoff); + beforeCount.Text = $"{count} item{(count == 1 ? "" : "s")} before {cutoff:MMMM d, yyyy}"; + } + + beforeRadio.IsCheckedChanged += (_, _) => beforePanel.IsEnabled = beforeRadio.IsChecked == true; + beforePicker.SelectedDateChanged += (_, _) => UpdateBeforeCount(); + UpdateBeforeCount(); + + var body = new StackPanel { Width = 340 }; + body.Children.Add(new TextBlock { + Text = "This can't be undone. Export a backup if you want to save any schedule", + TextWrapping = TextWrapping.Wrap, + Opacity = 0.7, + Margin = new Thickness(0, 0, 0, 12) + }); + body.Children.Add(completedRadio); + body.Children.Add(beforeRadio); + body.Children.Add(beforePanel); + body.Children.Add(everythingRadio); + + var dialog = new FAContentDialog { + Title = "Delete Schedule Items", + Content = body, + PrimaryButtonText = "Delete", + CloseButtonText = "Cancel", + DefaultButton = FAContentDialogButton.Close + }; + if (await dialog.ShowAsync() != FAContentDialogResult.Primary) return null; + + if (everythingRadio.IsChecked == true) return (BulkDeleteMode.Everything, DateTime.MinValue); + if (beforeRadio.IsChecked != true) return (BulkDeleteMode.Completed, DateTime.MinValue); + + if (beforePicker.SelectedDate is not { } date) { + await ShowMessageAsync("Delete Schedule Items", "Pick a date to delete before"); + return null; + } + + return (BulkDeleteMode.Before, date.Date); + } + + private static async Task ConfirmDeleteEverythingAsync(int total) { + var dialog = new FAContentDialog { + Title = "Delete everything?", + PrimaryButtonText = $"Delete all {total}", + CloseButtonText = "Cancel", + DefaultButton = FAContentDialogButton.Close, + Content = new TextBlock { + Text = $"All {total} schedule item{(total == 1 ? "" : "s")} will be permanently removed", + TextWrapping = TextWrapping.Wrap, + Width = 320, + Margin = new Thickness(4) + } + }; + return await dialog.ShowAsync() == FAContentDialogResult.Primary; + } + + private enum BulkDeleteMode { + Completed, + Before, + Everything + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/Schedule/ScheduleView.Csv.cs b/SubathonManager.UI/Views/Schedule/ScheduleView.Csv.cs new file mode 100644 index 00000000..3f39faff --- /dev/null +++ b/SubathonManager.UI/Views/Schedule/ScheduleView.Csv.cs @@ -0,0 +1,258 @@ +using System.Text; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using FluentAvalonia.UI.Controls; +using Microsoft.EntityFrameworkCore; +using SubathonManager.Core; +using SubathonManager.Core.Models; +using SubathonManager.Data; + +namespace SubathonManager.UI.Views.Schedule; + +public partial class ScheduleView { + private const int MaxErrorsShown = 8; + + private async void ExportCsv_Click(object? sender, RoutedEventArgs e) { + CommitEditorIfDirty(); + + int total; + await using (AppDbContext db = await _factory.CreateDbContextAsync()) { + total = await db.ScheduleItems.CountAsync(); + } + + if (total == 0) { + await ShowMessageAsync("Export Schedule", "There is nothing in the schedule to export"); + return; + } + + (DateTime from, DateTime to)? range = await AskExportRangeAsync(total); + if (range == null) return; + bool everything = range.Value.from == DateTime.MinValue; + + List items; + await using (AppDbContext db = await _factory.CreateDbContextAsync()) { + IQueryable query = db.ScheduleItems.AsNoTracking(); + if (!everything) { + DateTime start = range.Value.from; + DateTime end = range.Value.to.AddDays(1); + query = query.Where(i => i.Date >= start && i.Date < end); + } + + items = await query.OrderBy(i => i.Date).ThenBy(i => i.SortOrder).ThenBy(i => i.CreatedAt).ToListAsync(); + } + + if (items.Count == 0) { + await ShowMessageAsync("Export Schedule", "Nothing is planned in that date range"); + return; + } + + var top = TopLevel.GetTopLevel(this); + if (top == null) return; + + string suggested = everything + ? "schedule-all" + : range.Value.from == range.Value.to + ? $"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"); + Directory.CreateDirectory(exportDir); + IStorageFolder? startFolder = await top.StorageProvider.TryGetFolderFromPathAsync(exportDir); + + IStorageFile? picked = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { + Title = "Export Schedule", + SuggestedFileName = suggested, + DefaultExtension = "csv", + SuggestedStartLocation = startFolder, + FileTypeChoices = [new FilePickerFileType("CSV Files") { Patterns = ["*.csv"] }] + }); + if (picked == null) return; + + string csv = ScheduleCsv.Write(items); + try { + if (picked.TryGetLocalPath() is { } path) { + await File.WriteAllTextAsync(path, csv, new UTF8Encoding(true)); + } + else { + await using Stream stream = await picked.OpenWriteAsync(); + stream.SetLength(0); + await using var writer = new StreamWriter(stream, new UTF8Encoding(true)); + await writer.WriteAsync(csv); + } + } + catch (Exception ex) { + await ShowMessageAsync("Export Schedule", $"Could not write the file.\n\n{ex.Message}"); + } + } + + private async Task<(DateTime from, DateTime to)?> AskExportRangeAsync(int total) { + DateTime monthStart = _displayMonth; + DateTime monthEnd = _displayMonth.AddMonths(1).AddDays(-1); + + var allRadio = new RadioButton { + GroupName = "ScheduleExportRange", + Content = $"Everything ({total} item{(total == 1 ? "" : "s")})", + IsChecked = true + }; + var rangeRadio = new RadioButton { + GroupName = "ScheduleExportRange", + Content = "Date range", + Margin = new Thickness(0, 6, 0, 0) + }; + var fromPicker = new DatePicker { SelectedDate = new DateTimeOffset(monthStart) }; + var toPicker = new DatePicker { SelectedDate = new DateTimeOffset(monthEnd) }; + var countText = new TextBlock { FontSize = 12, Margin = new Thickness(0, 10, 0, 0), Opacity = 0.7 }; + + var rangePanel = new StackPanel { Margin = new Thickness(28, 8, 0, 0), IsEnabled = false, Spacing = 6 }; + rangePanel.Children.Add(Labelled("From", fromPicker)); + rangePanel.Children.Add(Labelled("To", toPicker)); + rangePanel.Children.Add(countText); + + async void UpdateCount() { + if (rangeRadio.IsChecked != true || fromPicker.SelectedDate is not { } f || + toPicker.SelectedDate is not { } t) { + countText.Text = ""; + return; + } + + (DateTime start, DateTime end) = OrderedRange(f.Date, t.Date); + DateTime endExclusive = end.AddDays(1); + await using AppDbContext db = await _factory.CreateDbContextAsync(); + int count = await db.ScheduleItems.CountAsync(i => i.Date >= start && i.Date < endExclusive); + countText.Text = $"{count} item{(count == 1 ? "" : "s")} in range"; + } + + rangeRadio.IsCheckedChanged += (_, _) => { + rangePanel.IsEnabled = rangeRadio.IsChecked == true; + UpdateCount(); + }; + fromPicker.SelectedDateChanged += (_, _) => UpdateCount(); + toPicker.SelectedDateChanged += (_, _) => UpdateCount(); + + var body = new StackPanel { Width = 340 }; + body.Children.Add(allRadio); + body.Children.Add(rangeRadio); + body.Children.Add(rangePanel); + + var dialog = new FAContentDialog { + Title = "Export Schedule", + Content = body, + PrimaryButtonText = "Export", + CloseButtonText = "Cancel", + DefaultButton = FAContentDialogButton.Primary + }; + if (await dialog.ShowAsync() != FAContentDialogResult.Primary) return null; + + if (rangeRadio.IsChecked != true) return (DateTime.MinValue, DateTime.MaxValue); + if (fromPicker.SelectedDate is not { } from || toPicker.SelectedDate is not { } to) { + await ShowMessageAsync("Export Schedule", "Pick both a start and an end date"); + return null; + } + + return OrderedRange(from.Date, to.Date); + } + + private static (DateTime, DateTime) OrderedRange(DateTime a, DateTime b) { + return a <= b ? (a, b) : (b, a); + } + + private static StackPanel Labelled(string label, Control control) { + var panel = new StackPanel(); + panel.Children.Add(new TextBlock { + Text = label, + FontSize = 11, + Margin = new Thickness(0, 0, 0, 3), + Opacity = 0.7 + }); + panel.Children.Add(control); + return panel; + } + + private async void ImportCsv_Click(object? sender, RoutedEventArgs e) { + CommitEditorIfDirty(); + + var top = TopLevel.GetTopLevel(this); + if (top == null) return; + + IReadOnlyList picked = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { + Title = "Import Schedule", + AllowMultiple = false, + FileTypeFilter = [new FilePickerFileType("CSV Files") { Patterns = ["*.csv"] }] + }); + if (picked.Count == 0) return; + + string text; + try { + await using Stream stream = await picked[0].OpenReadAsync(); + using var reader = new StreamReader(stream, Encoding.UTF8, true); + text = await reader.ReadToEndAsync(); + } + catch (Exception ex) { + await ShowMessageAsync("Import Schedule", $"Could not read the file.\n\n{ex.Message}"); + return; + } + + ScheduleCsv.ParseResult parsed = ScheduleCsv.Parse(text); + if (parsed.Items.Count == 0) { + string reason = parsed.Errors.Count > 0 + ? FormatErrors(parsed.Errors) + : "The file has no schedule rows."; + await ShowMessageAsync("Import Schedule", $"Nothing was imported.\n\n{reason}"); + return; + } + + ScheduleCsv.ImportPlan plan; + await using (AppDbContext db = await _factory.CreateDbContextAsync()) { + DateTime minDate = parsed.Items.Min(i => i.Date); + DateTime maxDate = parsed.Items.Max(i => i.Date).AddDays(1); + List existing = await db.ScheduleItems + .Where(i => i.Date >= minDate && i.Date < maxDate) + .OrderBy(i => i.Date).ThenBy(i => i.SortOrder) + .ToListAsync(); + + plan = ScheduleCsv.PlanImport(existing, parsed.Items); + db.ScheduleItems.AddRange(plan.ToAdd); + await db.SaveChangesAsync(); + } + + CloseEditor(); + LoadDay(); + RenderCalendar(); + RenderUpcoming(); + NotifyScheduleChanged(); + + var summary = new StringBuilder(); + summary.AppendLine($"Added: {plan.ToAdd.Count}"); + summary.AppendLine($"Descriptions updated: {plan.ToUpdate.Count}"); + summary.AppendLine($"Already up to date: {plan.Unchanged}"); + if (parsed.Errors.Count > 0) { + summary.AppendLine($"Skipped rows: {parsed.Errors.Count}"); + summary.AppendLine(); + summary.Append(FormatErrors(parsed.Errors)); + } + + await ShowMessageAsync("Import Schedule", summary.ToString().TrimEnd()); + } + + private static string FormatErrors(List errors) { + string shown = string.Join("\n", errors.Take(MaxErrorsShown)); + return errors.Count > MaxErrorsShown ? $"{shown}\n...and {errors.Count - MaxErrorsShown} more" : shown; + } + + private static async Task ShowMessageAsync(string title, string message) { + var dialog = new FAContentDialog { + Title = title, + CloseButtonText = "OK", + Content = new TextBlock { + Text = message, + TextWrapping = TextWrapping.Wrap, + Width = 340, + Margin = new Thickness(4) + } + }; + await dialog.ShowAsync(); + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/Schedule/ScheduleView.Editor.cs b/SubathonManager.UI/Views/Schedule/ScheduleView.Editor.cs new file mode 100644 index 00000000..d7bb59ae --- /dev/null +++ b/SubathonManager.UI/Views/Schedule/ScheduleView.Editor.cs @@ -0,0 +1,323 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using FluentAvalonia.UI.Controls; +using Microsoft.EntityFrameworkCore; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; +using SubathonManager.Data; +using SubathonManager.UI.UiUtils; + +namespace SubathonManager.UI.Views.Schedule; + +public partial class ScheduleView { + private void AddEvent_Click(object? sender, RoutedEventArgs e) { + StartNewItem(ScheduleItemKind.Event); + } + + private void AddTask_Click(object? sender, RoutedEventArgs e) { + StartNewItem(ScheduleItemKind.Task); + } + + private void StartNewItem(ScheduleItemKind kind) { + CommitEditorIfDirty(); + var item = new ScheduleItem { + Date = _selectedDate, + Kind = kind, + StartMinute = kind == ScheduleItemKind.Event ? DefaultStartMinute() : null + }; + OpenEditor(item, true); + Dispatcher.UIThread.Post(() => TitleBox.Focus(), DispatcherPriority.Background); + } + + private int DefaultStartMinute() { + int? lastEnd = _dayItems.Where(i => i.StartMinute != null) + .Select(i => i.EndMinute ?? i.StartMinute) + .Max(); + if (lastEnd is { } end and < 23 * 60) return end; + if (_selectedDate == DateTime.Today) { + DateTime now = DateTime.Now.AddMinutes(30); + return now.Date == DateTime.Today ? now.Hour * 60 : 12 * 60; + } + + return 12 * 60; + } + + private void OpenEditor(ScheduleItem item, bool isNew) { + _editing = item; + _editingIsNew = isNew; + + _suppressCount++; + try { + KindBox.SelectedItem = item.Kind.ToString(); + TitleBox.Text = item.Title; + DescriptionBox.Text = item.Description; + ItemDatePicker.SelectedDate = new DateTimeOffset(item.Date); + AllDayCheck.IsChecked = item.IsAllDay; + StartTimeBox.Text = item.StartMinute is { } s ? ScheduleItem.FormatMinute(s) : ""; + EndTimeBox.Text = item.EndMinute is { } en ? ScheduleItem.FormatMinute(en) : ""; + TimePanel.IsEnabled = !item.IsAllDay; + } + finally { + _suppressCount--; + } + + DeleteItemBtn.IsVisible = !isNew; + EditorValidationMsg.Text = ""; + SaveItemBtn.Content = isNew ? "Add" : "Save"; + EditorEmptyText.IsVisible = false; + EditorPanel.IsVisible = true; + UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, isNew); + UpdateRowSelection(); + } + + private void CloseEditor() { + _editing = null; + _editingIsNew = false; + EditorPanel.IsVisible = false; + EditorEmptyText.IsVisible = true; + UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, false); + UpdateRowSelection(); + } + + private void CloseEditor_Click(object? sender, RoutedEventArgs e) { + CloseEditor(); + } + + private void EditorChanged() { + if (_suppressCount > 0 || _editing == null) return; + EditorValidationMsg.Text = ""; + UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, _editingIsNew || IsEditorDirty()); + } + + private void EditorText_Changed(object? sender, TextChangedEventArgs e) { + EditorChanged(); + } + + private void Kind_Changed(object? sender, SelectionChangedEventArgs e) { + EditorChanged(); + } + private void ItemDate_Changed(object? sender, DatePickerSelectedValueChangedEventArgs e) { + EditorChanged(); + } + + private void AllDay_Changed(object? sender, RoutedEventArgs e) { + TimePanel.IsEnabled = AllDayCheck.IsChecked != true; + if (_suppressCount == 0 && AllDayCheck.IsChecked != true && string.IsNullOrWhiteSpace(StartTimeBox.Text)) + StartTimeBox.Text = ScheduleItem.FormatMinute(DefaultStartMinute()); + EditorChanged(); + } + + private void TimeBox_LostFocus(object? sender, RoutedEventArgs e) { + if (sender is not TextBox box) return; + if (ScheduleItem.TryParseTime(box.Text, out int? minute) && minute is { } m) + box.Text = ScheduleItem.FormatMinute(m); + } + + private bool TryReadEditor(out ScheduleItem values, out string error) { + values = new ScheduleItem(); + error = ""; + + if (ItemDatePicker.SelectedDate is not { } picked) { + error = "Pick a date."; + return false; + } + + values.Date = picked.Date; + values.Kind = Enum.TryParse($"{KindBox.SelectedItem}", out ScheduleItemKind kind) + ? kind + : ScheduleItemKind.Event; + values.Title = (TitleBox.Text ?? "").Trim(); + values.Description = (DescriptionBox.Text ?? "").TrimEnd(); + + if (values.Title.Length == 0) { + error = "Give it a title."; + return false; + } + + if (AllDayCheck.IsChecked == true) return true; + + if (!ScheduleItem.TryParseTime(StartTimeBox.Text, out int? start)) { + error = "Start time must be HH:MM (00:00 - 23:59)"; + return false; + } + + if (start == null) { + error = "Set a start time, or tick \"On the day\""; + return false; + } + + if (!ScheduleItem.TryParseTime(EndTimeBox.Text, out int? end)) { + error = "End time must be HH:MM (00:00 - 23:59), or left blank"; + return false; + } + + if (end == start) end = null; + values.StartMinute = start; + values.EndMinute = end; + return true; + } + + private bool IsEditorDirty() { + if (_editing == null) return false; + if (_editingIsNew) + return !string.IsNullOrWhiteSpace(TitleBox.Text) || !string.IsNullOrWhiteSpace(DescriptionBox.Text); + + if (!TryReadEditor(out ScheduleItem v, out _)) return true; + return v.Date != _editing.Date.Date || v.Kind != _editing.Kind || v.Title != _editing.Title || + v.Description != _editing.Description.TrimEnd() || v.StartMinute != _editing.StartMinute || + v.EndMinute != _editing.EndMinute; + } + + private void CommitEditorIfDirty() { + if (_editing == null || !IsEditorDirty()) return; + if (TryReadEditor(out _, out _)) SaveEditor(false); + } + + private async void SaveItem_Click(object? sender, RoutedEventArgs e) { + if (_editing == null) return; + if (!SaveEditor(true)) return; + + SaveItemBtn.Content = "Saved!"; + await Task.Delay(1200); + if (_editing != null && !_editingIsNew) SaveItemBtn.Content = "Save"; + } + + private bool SaveEditor(bool reopen) { + if (_editing == null) return false; + if (!TryReadEditor(out ScheduleItem v, out string error)) { + EditorValidationMsg.Text = error; + return false; + } + + ScheduleItem saved; + using (AppDbContext db = _factory.CreateDbContext()) { + ScheduleItem? target = _editingIsNew ? null : db.ScheduleItems.Find(_editing.Id); + bool isNew = target == null; + target ??= new ScheduleItem { Id = _editing.Id, IsDone = _editing.IsDone, CreatedAt = DateTime.Now }; + + if (isNew || target.Date.Date != v.Date) { + DateTime next = v.Date.AddDays(1); + int maxOrder = db.ScheduleItems + .Where(i => i.Date >= v.Date && i.Date < next && i.Id != target.Id) + .Select(i => (int?)i.SortOrder) + .Max() ?? -1; + target.SortOrder = maxOrder + 1; + } + + target.Date = v.Date; + target.Kind = v.Kind; + target.Title = v.Title; + target.Description = v.Description; + target.StartMinute = v.StartMinute; + target.EndMinute = v.EndMinute; + + if (isNew) db.ScheduleItems.Add(target); + db.SaveChanges(); + saved = target; + } + + UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, false); + + if (!reopen) { + _editing = null; + _editingIsNew = false; + LoadDay(); + RenderCalendar(); + RenderUpcoming(); + NotifyScheduleChanged(); + return true; + } + + if (saved.Date != _selectedDate) { + _editing = null; + SelectDate(saved.Date, saved.Id); + NotifyScheduleChanged(); + return true; + } + + LoadDay(); + RenderCalendar(); + RenderUpcoming(); + NotifyScheduleChanged(); + ScheduleItem? reloaded = _dayItems.FirstOrDefault(i => i.Id == saved.Id); + if (reloaded != null) OpenEditor(reloaded, false); + return true; + } + + private async void DeleteItem_Click(object? sender, RoutedEventArgs e) { + await DeleteSelectedAsync(); + } + + protected override void OnKeyDown(KeyEventArgs e) { + base.OnKeyDown(e); + if (e.Handled || e.Key != Key.Delete || e.KeyModifiers != KeyModifiers.None) return; + if (_editing == null || _editingIsNew) return; + if (IsWithin(e.Source) || IsWithin(e.Source) || IsWithin(e.Source)) return; + + e.Handled = true; + _ = DeleteSelectedAsync(); + } + + private async Task DeleteSelectedAsync() { + if (_editing == null) return; + if (_editingIsNew) { + CloseEditor(); + return; + } + + await DeleteItemAsync(_editing); + } + + private async Task DeleteItemAsync(ScheduleItem item) { + if (!await ConfirmDeleteAsync(item)) return; + + await using (AppDbContext db = await _factory.CreateDbContextAsync()) { + await db.ScheduleItems.Where(i => i.Id == item.Id).ExecuteDeleteAsync(); + } + + if (_editing?.Id == item.Id) CloseEditor(); + LoadDay(); + RenderCalendar(); + RenderUpcoming(); + NotifyScheduleChanged(); + } + + private async Task ConfirmDeleteAsync(ScheduleItem item) { + bool skip; + await using (AppDbContext db = await _factory.CreateDbContextAsync()) { + skip = StateValueHelper.Get(db, StateKeys.ScheduleSkipDeleteConfirm, false); + } + + if (skip) return true; + + var skipBox = new CheckBox { + Content = "Don't ask again", + Margin = new Thickness(0, 14, 0, 0) + }; + + var body = new StackPanel { Width = 320, Margin = new Thickness(4) }; + body.Children.Add(new TextBlock { + Text = $"\"{DisplayTitle(item)}\" will be removed from {item.Date:MMMM d}", + TextWrapping = TextWrapping.Wrap + }); + body.Children.Add(skipBox); + + var dialog = new FAContentDialog { + Title = $"Delete {item.Kind.ToString().ToLowerInvariant()}?", + PrimaryButtonText = "Delete", + CloseButtonText = "Cancel", + DefaultButton = FAContentDialogButton.Close, + Content = body + }; + if (await dialog.ShowAsync() != FAContentDialogResult.Primary) return false; + + if (skipBox.IsChecked == true) + await StateValueHelper.SetAsync(_factory, StateKeys.ScheduleSkipDeleteConfirm, true); + + return true; + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/Schedule/ScheduleView.Rows.cs b/SubathonManager.UI/Views/Schedule/ScheduleView.Rows.cs new file mode 100644 index 00000000..f9d345fe --- /dev/null +++ b/SubathonManager.UI/Views/Schedule/ScheduleView.Rows.cs @@ -0,0 +1,372 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; +using Microsoft.EntityFrameworkCore; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; +using SubathonManager.Data; +using SubathonManager.UI.Controls; + +namespace SubathonManager.UI.Views.Schedule; + +public partial class ScheduleView { + private void RenderRows() { + ItemsStack.Children.Clear(); + foreach (ScheduleItem item in _dayItems) + ItemsStack.Children.Add(BuildRow(item)); + NoItemsText.IsVisible = _dayItems.Count == 0; + UpdateRowSelection(); + } + + private Border BuildRow(ScheduleItem item) { + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,Auto,Auto,Auto,*") }; + + Border grip = CreateDragGrip(); + + var check = new CheckBox { + IsChecked = item.IsDone, + MinWidth = 0, + Margin = new Thickness(2, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center + }; + ToolTip.SetTip(check, "Mark as done"); + + bool isEvent = item.Kind == ScheduleItemKind.Event; + var kindIcon = new SymIcon { + Glyph = isEvent ? "CalendarToday20" : "TaskListSquare20", + Opacity = 0.8, + Margin = new Thickness(0, 0, 10, 0), + VerticalAlignment = VerticalAlignment.Center + }; + ToolTip.SetTip(kindIcon, isEvent ? "Event" : "Task"); + + var time = new TextBlock { + Text = item.TimeLabel(), + Width = 120, + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + Classes = { "muted" } + }; + + var text = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + var title = new TextBlock { + Text = DisplayTitle(item), + FontWeight = FontWeight.SemiBold, + TextTrimming = TextTrimming.CharacterEllipsis + }; + title.Classes.Add("title"); + text.Children.Add(title); + string firstLine = item.Description.Split('\n', 2)[0].Trim(); + if (firstLine.Length > 0) + text.Children.Add(new TextBlock { + Text = firstLine, + FontSize = 12, + Opacity = 0.65, + TextTrimming = TextTrimming.CharacterEllipsis + }); + + Grid.SetColumn(grip, 0); + Grid.SetColumn(check, 1); + Grid.SetColumn(kindIcon, 2); + Grid.SetColumn(time, 3); + Grid.SetColumn(text, 4); + grid.Children.Add(grip); + grid.Children.Add(check); + grid.Children.Add(kindIcon); + grid.Children.Add(time); + grid.Children.Add(text); + + var row = new Border { Child = grid, Tag = item }; + row.Classes.Add("schedrow"); + if (item.IsDone) row.Classes.Add("done"); + if (!string.IsNullOrWhiteSpace(item.Description)) ToolTip.SetTip(text, item.Description.Trim()); + + row.ContextFlyout = BuildRowMenu(item); + + check.IsCheckedChanged += (_, _) => SetDone(item, row, check.IsChecked == true); + row.Tapped += (_, e) => { + if (IsWithin(e.Source) || IsWithinGrip(e.Source)) return; + CommitEditorIfDirty(); + ScheduleItem? current = _dayItems.FirstOrDefault(i => i.Id == item.Id); + if (current == null) return; + OpenEditor(current, false); + ItemsListBorder.Focus(); + }; + + grip.PointerPressed += (_, e) => BeginRowDrag(grip, row, e); + grip.PointerMoved += (_, e) => RowDragMove(e); + grip.PointerReleased += (_, e) => { + e.Pointer.Capture(null); + FinishRowDrag(); + }; + grip.PointerCaptureLost += (_, _) => FinishRowDrag(); + return row; + } + + private MenuFlyout BuildRowMenu(ScheduleItem item) { + var duplicate = new MenuItem { Header = "Duplicate", Icon = new SymIcon { Glyph = "Copy16" } }; + duplicate.Click += (_, _) => DuplicateItem(item.Id); + + var delete = new MenuItem { Header = "Delete", Icon = new SymIcon { Glyph = "Delete16" } }; + delete.Click += async (_, _) => { + ScheduleItem? current = _dayItems.FirstOrDefault(i => i.Id == item.Id); + if (current != null) await DeleteItemAsync(current); + }; + + return new MenuFlyout { Items = { duplicate, new Separator(), delete } }; + } + + private void DuplicateItem(Guid id) { + CommitEditorIfDirty(); + + ScheduleItem? source = _dayItems.FirstOrDefault(i => i.Id == id); + if (source == null) return; + + var copy = new ScheduleItem { + Date = source.Date, + Kind = source.Kind, + Title = source.Title, + Description = source.Description, + StartMinute = source.StartMinute, + EndMinute = source.EndMinute + }; + + List order = _dayItems.Select(i => i.Id).ToList(); + order.Insert(order.IndexOf(id) + 1, copy.Id); + + DateTime day = _selectedDate; + DateTime next = day.AddDays(1); + using (AppDbContext db = _factory.CreateDbContext()) { + Dictionary tracked = db.ScheduleItems + .Where(i => i.Date >= day && i.Date < next) + .ToDictionary(i => i.Id); + tracked[copy.Id] = copy; + db.ScheduleItems.Add(copy); + for (var i = 0; i < order.Count; i++) + if (tracked.TryGetValue(order[i], out ScheduleItem? t)) + t.SortOrder = i; + db.SaveChanges(); + } + + LoadDay(); + RenderCalendar(); + RenderUpcoming(); + NotifyScheduleChanged(); + + ScheduleItem? created = _dayItems.FirstOrDefault(i => i.Id == copy.Id); + if (created == null) return; + OpenEditor(created, false); + ItemsListBorder.Focus(); + } + + private static bool IsWithin(object? source) where T : Visual { + var v = source as Visual; + while (v != null) { + if (v is T) return true; + v = v.GetVisualParent(); + } + + return false; + } + + private static bool IsWithinGrip(object? source) { + var v = source as Visual; + while (v != null) { + if (v is Border { Tag: "grip" }) return true; + v = v.GetVisualParent(); + } + + return false; + } + + private void UpdateRowSelection() { + foreach (Border row in ItemsStack.Children.OfType()) { + bool selected = _editing != null && !_editingIsNew && row.Tag is ScheduleItem i && i.Id == _editing.Id; + SetClass(row, "selected", selected); + } + } + + private void SetDone(ScheduleItem item, Border row, bool done) { + if (item.IsDone == done) return; + item.IsDone = done; + SetClass(row, "done", done); + + using (AppDbContext db = _factory.CreateDbContext()) { + db.ScheduleItems.Where(i => i.Id == item.Id) + .ExecuteUpdate(s => s.SetProperty(i => i.IsDone, done)); + } + + if (_editing != null && _editing.Id == item.Id) _editing.IsDone = done; + UpdateDaySummary(); + RenderCalendar(); + RenderUpcoming(); + NotifyScheduleChanged(); + } + + private Border CreateDragGrip() { + var grip = new Border { + Width = 18, + Tag = "grip", + Margin = new Thickness(0, 0, 2, 0), + Background = Brushes.Transparent, + VerticalAlignment = VerticalAlignment.Stretch, + Cursor = new Cursor(StandardCursorType.SizeAll), + Child = new SymIcon { + Glyph = "ReOrderDotsVertical20", + Opacity = 0.6, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + }; + ToolTip.SetTip(grip, "Drag to reorder, or drop on a calendar day to move it there"); + return grip; + } + + private List Rows() { + return ItemsStack.Children.OfType().ToList(); + } + + private void BeginRowDrag(Border grip, Border row, PointerPressedEventArgs e) { + if (!e.GetCurrentPoint(grip).Properties.IsLeftButtonPressed) return; + List rows = Rows(); + int index = rows.IndexOf(row); + if (index < 0) return; + + _dragRow = row; + _dragStartIndex = index; + row.Opacity = 0.6; + e.Pointer.Capture(grip); + e.Handled = true; + } + + private void RowDragMove(PointerEventArgs e) { + if (_dragRow == null) return; + + List rows = Rows(); + int current = rows.IndexOf(_dragRow); + if (current < 0) return; + + Border? dayCell = DayCellAt(e); + SetDropCell(dayCell); + if (dayCell != null) { + // over the calendar + if (current != _dragStartIndex && _dragStartIndex < rows.Count) + ItemsStack.Children.Move(current, _dragStartIndex); + return; + } + + if (rows.Count < 2) return; + + double y = e.GetPosition(ItemsStack).Y; + int target = current; + if (y <= rows[0].Bounds.Top) + target = 0; + else if (y >= rows[^1].Bounds.Bottom) + target = rows.Count - 1; + else + for (var i = 0; i < rows.Count; i++) { + if (y < rows[i].Bounds.Top || y > rows[i].Bounds.Bottom) continue; + target = i; + break; + } + + if (target == current) return; + ItemsStack.Children.Move(current, target); + } + + private void FinishRowDrag() { + if (_dragRow == null) return; + + Border row = _dragRow; + _dragRow = null; + row.Opacity = 1; + + int startIndex = _dragStartIndex; + _dragStartIndex = -1; + + Border? dropCell = _dropCell; + SetDropCell(null); + if (dropCell != null) { + if (dropCell.Tag is DateTime day && day != _selectedDate && row.Tag is ScheduleItem moving) + MoveItemToDay(moving.Id, day); + return; + } + + List ordered = Rows().Select(r => r.Tag).OfType().ToList(); + if (ordered.IndexOf((ScheduleItem)row.Tag!) == startIndex) return; + + PersistOrder(ordered); + } + + private Border? DayCellAt(PointerEventArgs e) { + Point p = e.GetPosition(DayGrid); + if (!new Rect(DayGrid.Bounds.Size).Contains(p)) return null; + return DayGrid.Children.OfType().FirstOrDefault(c => c.Bounds.Contains(p)); + } + + private void SetDropCell(Border? cell) { + if (ReferenceEquals(cell, _dropCell)) return; + if (_dropCell != null) SetClass(_dropCell, "droptarget", false); + _dropCell = cell; + if (cell != null) SetClass(cell, "droptarget", true); + } + + private void MoveItemToDay(Guid id, DateTime day) { + CommitEditorIfDirty(); + + using (AppDbContext db = _factory.CreateDbContext()) { + ScheduleItem? tracked = db.ScheduleItems.Find(id); + if (tracked == null || tracked.Date.Date == day) return; + + DateTime next = day.AddDays(1); + int maxOrder = db.ScheduleItems + .Where(i => i.Date >= day && i.Date < next) + .Select(i => (int?)i.SortOrder) + .Max() ?? -1; + tracked.Date = day; + tracked.SortOrder = maxOrder + 1; + db.SaveChanges(); + } + + if (_editing?.Id == id) CloseEditor(); + LoadDay(); + RenderCalendar(); + RenderUpcoming(); + NotifyScheduleChanged(); + } + + private void PersistOrder(List ordered) { + DateTime day = _selectedDate; + DateTime next = day.AddDays(1); + using AppDbContext db = _factory.CreateDbContext(); + Dictionary tracked = db.ScheduleItems + .Where(i => i.Date >= day && i.Date < next) + .ToDictionary(i => i.Id); + + for (var i = 0; i < ordered.Count; i++) { + ordered[i].SortOrder = i; + if (tracked.TryGetValue(ordered[i].Id, out ScheduleItem? t)) t.SortOrder = i; + } + + db.SaveChanges(); + _dayItems = ordered; + RenderCalendar(); + RenderUpcoming(); + NotifyScheduleChanged(); + } + + private void SortByTime_Click(object? sender, RoutedEventArgs e) { + if (_dayItems.Count < 2) return; + List ordered = _dayItems + .OrderBy(i => i.StartMinute.HasValue ? 1 : 0) + .ThenBy(i => i.StartMinute ?? 0) + .ThenBy(i => i.SortOrder) + .ToList(); + PersistOrder(ordered); + RenderRows(); + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/Schedule/ScheduleView.axaml b/SubathonManager.UI/Views/Schedule/ScheduleView.axaml new file mode 100644 index 00000000..805d8294 --- /dev/null +++ b/SubathonManager.UI/Views/Schedule/ScheduleView.axaml @@ -0,0 +1,274 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +