diff --git a/README.md b/README.md index a042a3c..875e947 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ - UwuMarket - GamerSupps +- Orchid Eight +- KatDragonz +
KoFi diff --git a/SubathonManager.Core/Enums/EnumExtensions.cs b/SubathonManager.Core/Enums/EnumExtensions.cs new file mode 100644 index 0000000..b1eb123 --- /dev/null +++ b/SubathonManager.Core/Enums/EnumExtensions.cs @@ -0,0 +1,140 @@ +using System.Reflection; +using System.Collections.Concurrent; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Core.Enums; + +public static class EnumExtensions +{ + public static string GetDescription(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + if (field == null) return value.ToString(); + + var attr = field.GetCustomAttribute(); + return attr?.Description ?? value.ToString(); + } + + public static string GetLabel(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + if (field == null) return value.ToString(); + + var attr = field.GetCustomAttribute(); + return attr?.Label ?? value.ToString(); + } + + public static bool IsDisabled(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + if (field == null) return true; + var attr = field.GetCustomAttribute(); + return !attr?.Enabled ?? true; + } + + public static bool IsEnabled(this Enum value) + { + return !IsDisabled(value); + } + + public static int GetOrderNumber(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + if (field == null) return 99999; + var attr = field.GetCustomAttribute(); + return attr?.Order ?? 99999; + } + +} + +[AttributeUsage(AttributeTargets.Field)] +public class EnumMetaAttribute : Attribute +{ + public virtual string? Description { get; set; } = ""; + public virtual string? Label { get; init; } + public int Order { get; init; } + + public bool Enabled { get; init; } = true; +} + +public class EventSourceMetaAttribute : EnumMetaAttribute +{ + public override string? Label => SourceGroup is (SubathonSourceGroup.UseSource or SubathonSourceGroup.Unknown) ? ToString() : SourceGroup.GetLabel(); + public SubathonSourceGroup SourceGroup { get; init; } = SubathonSourceGroup.UseSource; + + public int SourceOrder { get; init; } = 99999; +} + +public class EventTypeMetaAttribute : EnumMetaAttribute +{ + public override string? Description => Source is not (SubathonEventSource.Command or SubathonEventSource.Unknown) ? $"{Source.ToString()} {Label}".Trim( ): Label; + public bool IsCurrencyDonation { get; init; } + public bool IsGift { get; init; } + public bool IsMembership { get; init; } + public bool IsSubscription { get; init; } + public bool IsSubscriptionLike => IsSubscription || IsGift || IsMembership; + public bool IsExternal { get; init; } + + public bool IsExtension { get; init; } + public bool IsToken { get; init; } + public bool IsRaid { get; init; } + public bool IsTrain { get; init; } + public bool IsFollow { get; init; } + public bool IsOrder { get; init; } + public bool IsCommand { get; init; } + public bool IsOther { get; init; } + public bool HasValueConfig { get; init; } = true; + + public SubathonEventSource Source { get; set; } = SubathonEventSource.Unknown; +} + +public class GoAffProTypeMetaAttribute : EventTypeMetaAttribute +{ + public override string? Description => Label; + public GoAffProSource StoreSource { get; init; } = GoAffProSource.Unknown; +} + + +public class GoAffProSourceMetaAttribute : EnumMetaAttribute +{ + public SubathonEventType OrderEvent { get; init; } = SubathonEventType.Unknown; + + public int SiteId { get; init; } = -1; +} + +public class CommandMetaAttribute : EnumMetaAttribute +{ + public bool RequiresParameter { get; init; } + public bool IsControlType { get; init; } +} + + +public static class EnumMetaCache +{ + private static readonly ConcurrentDictionary> Cache = new(); + + public static T? Get(Enum value) where T : EnumMetaAttribute + { + var type = value.GetType(); + + var map = Cache.GetOrAdd(type, t => + { + var dict = new Dictionary(); + + foreach (var field in t.GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var enumValue = field.GetValue(null)!; + var attr2 = field.GetCustomAttribute(); + + dict[enumValue] = attr2; + } + + return dict; + }); + + if (!map.TryGetValue(value, out var attr)) + return null; + + return attr as T; + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/GoAffProSource.cs b/SubathonManager.Core/Enums/GoAffProSource.cs index 60ce78d..307a8f7 100644 --- a/SubathonManager.Core/Enums/GoAffProSource.cs +++ b/SubathonManager.Core/Enums/GoAffProSource.cs @@ -1,12 +1,20 @@ using System.Diagnostics.CodeAnalysis; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Core.Enums; public enum GoAffProSource { + [GoAffProSourceMeta(Description="Unknown", Enabled=false)] Unknown, + [GoAffProSourceMeta(Description="GamerSupps", SiteId=165328, OrderEvent = SubathonEventType.GamerSuppsOrder)] GamerSupps, - UwUMarket + [GoAffProSourceMeta(Description="UwUMarket", SiteId=132230, OrderEvent = SubathonEventType.UwUMarketOrder)] + UwUMarket, + [GoAffProSourceMeta(SiteId=7142837, OrderEvent = SubathonEventType.OrchidEightOrder, Description = "Orchid Eight", Label = "Orchid Eight")] + OrchidEight, + [GoAffProSourceMeta(Description="KatDragonz", SiteId=7160049, OrderEvent = SubathonEventType.KatDragonzOrder, Enabled=true)] + KatDragonz } public enum GoAffProModes @@ -19,13 +27,34 @@ public enum GoAffProModes [ExcludeFromCodeCoverage] public static class GoAffProSourceeHelper { - public static SubathonEventType GetOrderEvent(this GoAffProSource source) + private static GoAffProSourceMetaAttribute? Meta(this GoAffProSource? value) { - return source switch - { - GoAffProSource.GamerSupps => SubathonEventType.GamerSuppsOrder, - GoAffProSource.UwUMarket => SubathonEventType.UwUMarketOrder, - _ => SubathonEventType.Unknown - }; + if (!value.HasValue) return null; + var meta = EnumMetaCache.Get(value); + return meta; } + + private static readonly Lazy> SiteIdToSource = + new(() => + Enum.GetValues() + .Select(e => (Source: e, Meta: ((GoAffProSource?)e).Meta())) + .Where(x => x.Meta?.SiteId > 0 && x.Meta != null) + .ToDictionary(x => x.Meta!.SiteId, x => x.Source) + ); + + public static bool TryGetSource(int siteId, out GoAffProSource source) => + SiteIdToSource.Value.TryGetValue(siteId, out source); + + public static int GetSiteId(this GoAffProSource source) => + ((GoAffProSource?)source).Meta()?.SiteId ?? -1; + + public static bool TryGetSiteId(this GoAffProSource source, out int siteId) + { + siteId = ((GoAffProSource?)source).Meta()?.SiteId ?? -1; + return siteId != -1; + } + + public static SubathonEventType GetOrderEvent(this GoAffProSource source) => + ((GoAffProSource?)source).Meta()?.OrderEvent ?? SubathonEventType.Unknown; + } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonCommandType.cs b/SubathonManager.Core/Enums/SubathonCommandType.cs index 63c0f51..9d283bc 100644 --- a/SubathonManager.Core/Enums/SubathonCommandType.cs +++ b/SubathonManager.Core/Enums/SubathonCommandType.cs @@ -1,61 +1,58 @@ -using System.Diagnostics.CodeAnalysis; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; namespace SubathonManager.Core.Enums; public enum SubathonCommandType { + [CommandMeta(Description="Add Points", RequiresParameter = true)] AddPoints, + [CommandMeta(Description="Remove Points", RequiresParameter = true)] SubtractPoints, + [CommandMeta(Description="Set Points", RequiresParameter = true, IsControlType = true)] SetPoints, + [CommandMeta(Description="Add Time", RequiresParameter = true)] AddTime, + [CommandMeta(Description="Remove Time", RequiresParameter = true)] SubtractTime, + [CommandMeta(Description="Set Time", RequiresParameter = true, IsControlType = true)] SetTime, + [CommandMeta(Description="Lock", IsControlType = true)] Lock, + [CommandMeta(Description="Unlock", IsControlType = true)] Unlock, + [CommandMeta(Description="Pause Timer", IsControlType = true)] Pause, + [CommandMeta(Description="Resume Timer", IsControlType = true)] Resume, + [CommandMeta(Description="Set Multiplier", RequiresParameter = true, IsControlType = true)] SetMultiplier, + [CommandMeta(Description="Stop Multiplier", IsControlType = true)] StopMultiplier, + [CommandMeta(Description="None")] None, + [CommandMeta(Description="Unknown")] Unknown, + [CommandMeta(Description="Refresh Overlays", IsControlType = true)] RefreshOverlays, + [CommandMeta(Description="Add Money", RequiresParameter = true)] AddMoney, + [CommandMeta(Description="Remove Money", RequiresParameter = true)] SubtractMoney } [ExcludeFromCodeCoverage] public static class SubathonCommandTypeHelper { - private static readonly SubathonCommandType[] ParamRequiredCommands = new[] + private static CommandMetaAttribute? Meta(this SubathonCommandType value) { - SubathonCommandType.AddPoints, - SubathonCommandType.SubtractPoints, - SubathonCommandType.SetPoints, - SubathonCommandType.AddTime, - SubathonCommandType.SubtractTime, - SubathonCommandType.SetTime, - SubathonCommandType.SetMultiplier, - SubathonCommandType.AddMoney, - SubathonCommandType.SubtractMoney - }; + var meta = EnumMetaCache.Get(value); + return meta; + } - private static readonly SubathonCommandType[] ControlTypeCommands = new[] // can't "undo" - { - SubathonCommandType.Resume, - SubathonCommandType.Pause, - SubathonCommandType.StopMultiplier, - SubathonCommandType.Lock, - SubathonCommandType.Unlock, - SubathonCommandType.SetMultiplier, - SubathonCommandType.RefreshOverlays, - SubathonCommandType.SetPoints, - SubathonCommandType.SetTime - }; - - - public static bool IsParametersRequired(this SubathonCommandType command) => - ParamRequiredCommands.Contains(command); + public static bool IsParametersRequired(this SubathonCommandType command) => + command.Meta()?.RequiresParameter ?? false; public static bool IsControlTypeCommand(this SubathonCommandType command) => - ControlTypeCommands.Contains(command); + command.Meta()?.IsControlType ?? false; } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonEventSource.cs b/SubathonManager.Core/Enums/SubathonEventSource.cs index f502801..120cc1e 100644 --- a/SubathonManager.Core/Enums/SubathonEventSource.cs +++ b/SubathonManager.Core/Enums/SubathonEventSource.cs @@ -1,52 +1,53 @@ -using System.Diagnostics.CodeAnalysis; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; namespace SubathonManager.Core.Enums; public enum SubathonEventSource { // some may not actually be event sources in the future, but also integration sources + [EventSourceMeta(Description = "Twitch", SourceGroup = SubathonSourceGroup.Stream, SourceOrder=1, Order=10)] Twitch, + [EventSourceMeta(Description = "StreamElements", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=21, Order=20)] StreamElements, + [EventSourceMeta(Description = "KoFi", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=41, Order=40)] KoFi, + [EventSourceMeta(Description = "YouTube", SourceGroup = SubathonSourceGroup.Stream, SourceOrder=2, Order=11)] YouTube, + [EventSourceMeta(Description = "Commands", SourceGroup = SubathonSourceGroup.Misc, SourceOrder=2000, Order=100)] Command, // can be from any chat - Simulated, // buttons to test in UI? + [EventSourceMeta(Description = "Simulated", SourceOrder=8000, Order=199)] + Simulated, // buttons to test in UI? + [EventSourceMeta(Description = "Unknown", SourceGroup=SubathonSourceGroup.Misc, SourceOrder=9000, Order=101)] Unknown, // default + [EventSourceMeta(Description = "StreamLabs", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=22, Order=21)] StreamLabs, + [EventSourceMeta(Description = "Generic External Services", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=1000, Order=99)] External, + [EventSourceMeta(Description = "Blerp", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=81, Order=30)] Blerp, + [EventSourceMeta(Description = "Picarto", SourceGroup = SubathonSourceGroup.Stream, SourceOrder=3, Order=12)] Picarto, + [EventSourceMeta(Description = "GoAffPro Affiliate Stores", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=61, Order=50)] GoAffPro } [ExcludeFromCodeCoverage] public static class SubathonEventSourceHelper { - private static readonly List SourceOrder = - [ - SubathonEventSource.Twitch, - SubathonEventSource.YouTube, - SubathonEventSource.Picarto, - SubathonEventSource.StreamElements, - SubathonEventSource.StreamLabs, - SubathonEventSource.KoFi, - SubathonEventSource.GoAffPro, - SubathonEventSource.Blerp - ]; - - private static readonly List SourceOrderEnd = - [ - SubathonEventSource.External, SubathonEventSource.Command - ]; - - public static int GetSourceOrder(SubathonEventSource source) + public static int GetSourceOrder(SubathonEventSource source) => ((SubathonEventSource?)source).Meta()?.SourceOrder ?? 99999; + + private static EventSourceMetaAttribute? Meta(this SubathonEventSource? value) { - var idx = SourceOrder.IndexOf(source); - if (idx >= 0) return idx; + if (!value.HasValue) return null; + var meta = EnumMetaCache.Get(value); + return meta; + } - var endIdx = SourceOrderEnd.IndexOf(source); - if (endIdx >= 0) return SourceOrder.Count + 1000 + endIdx; + public static SubathonSourceGroup GetGroup(this SubathonEventSource source) => + ((SubathonEventSource?)source).Meta()?.SourceGroup ?? SubathonSourceGroup.Unknown; - return SourceOrder.Count + endIdx; - } + public static string GetGroupLabel(this SubathonEventSource source) => ((SubathonEventSource?)source).Meta()?.Label ?? source.ToString(); + + public static int GetGroupLabelOrder(this SubathonEventSource source) => ((SubathonEventSource?)source).Meta()?.Order ?? 99999; } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonEventSubType.cs b/SubathonManager.Core/Enums/SubathonEventSubType.cs index 07e5cca..c796520 100644 --- a/SubathonManager.Core/Enums/SubathonEventSubType.cs +++ b/SubathonManager.Core/Enums/SubathonEventSubType.cs @@ -3,27 +3,54 @@ using System.Diagnostics.CodeAnalysis; public enum SubathonEventSubType { + [EnumMeta(Description="Unknown",Label="Unknown", Order = 200)] Unknown, + [EnumMeta(Description="Subscriptions", Label="Subscriptions", Order=1)] SubLike, + [EnumMeta(Description="Gift Subscriptions", Label="Gift Subscriptions", Order=2)] GiftSubLike, + [EnumMeta(Description="Donations", Label="Donations", Order=4)] DonationLike, + [EnumMeta(Description="Tokens/Bits", Label="Tokens/Bits", Order=3)] TokenLike, + [EnumMeta(Description="Follows", Label="Follows", Order=5)] FollowLike, + [EnumMeta(Description="Raids", Label="Raids" , Order=6)] RaidLike, + [EnumMeta(Description="Hype Trains", Label="Hype Trains", Order = 7)] TrainLike, + [EnumMeta(Description="Commands", Label="Commands", Order = 100)] CommandLike, + [EnumMeta(Description="Store Orders", Label="Store Orders", Order=8)] OrderLike } [ExcludeFromCodeCoverage] public static class SubathonEventSubTypeHelper -{ - private static readonly SubathonEventSubType[] NotTrueEvent = new[] - { +{ + public static readonly List OrderEventTypes = Enum.GetValues() + .Where(e => e.GetSubType() == SubathonEventSubType.OrderLike && e.IsEnabled()) + .ToList(); + public static readonly List FollowEventTypes = Enum.GetValues() + .Where(e => e.GetSubType() == SubathonEventSubType.FollowLike && e.IsEnabled()) + .ToList(); + public static readonly List SubEventTypes = Enum.GetValues() + .Where(e => e.GetSubType() is SubathonEventSubType.SubLike or SubathonEventSubType.GiftSubLike && e.IsEnabled()) + .ToList(); + public static readonly List TokenEventTypes = Enum.GetValues() + .Where(e => e.GetSubType() == SubathonEventSubType.TokenLike && e.IsEnabled()) + .ToList(); + public static readonly List DonationEventTypes = Enum.GetValues() + .Where(e => e.GetSubType() == SubathonEventSubType.DonationLike && e.IsEnabled()) + .ToList(); + + private static readonly SubathonEventSubType[] NotTrueEvent = + [ SubathonEventSubType.CommandLike, - SubathonEventSubType.Unknown, - }; + SubathonEventSubType.Unknown + ]; public static bool IsTrueEvent(this SubathonEventSubType? eventType) => eventType.HasValue && !NotTrueEvent.Contains(eventType.Value); + } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonEventType.cs b/SubathonManager.Core/Enums/SubathonEventType.cs index f6ab183..feec98a 100644 --- a/SubathonManager.Core/Enums/SubathonEventType.cs +++ b/SubathonManager.Core/Enums/SubathonEventType.cs @@ -3,221 +3,151 @@ namespace SubathonManager.Core.Enums; public enum SubathonEventType { + [EventTypeMeta(Label="Subscription", Source=SubathonEventSource.Twitch, IsSubscription = true, Order = 1)] TwitchSub, // remember subs can be of Value: 1000, 2000, 3000, Prime iirc... damnit looks like the TwitchLib doesnt separate Prime?? + [EventTypeMeta(Label="Bits", Source=SubathonEventSource.Twitch, IsToken = true, Order = 3)] TwitchCheer, // remember 100 is 1$, either in UI we say per 100 bits and divide, or we make em divide + [EventTypeMeta(Label="Gift Subscription", Source=SubathonEventSource.Twitch, IsGift = true, Order = 2)] TwitchGiftSub, + [EventTypeMeta(Label="Raid", Source=SubathonEventSource.Twitch, IsRaid = true, Order = 5)] TwitchRaid, + [EventTypeMeta(Label="Follow", Source=SubathonEventSource.Twitch, IsFollow = true, Order = 4)] TwitchFollow, + [EventTypeMeta(Label="Donation", Source=SubathonEventSource.StreamElements, IsCurrencyDonation = true, Order = 1)] StreamElementsDonation, + + [EventTypeMeta(Label="Commands", Source=SubathonEventSource.Command, HasValueConfig = false, IsCommand = true, IsOther = true, Order =1)] Command, // from any chat or ui + [EventTypeMeta(Label="Unknown", Source=SubathonEventSource.Unknown, HasValueConfig = false, IsOther = true, Order =1)] Unknown, + + [EventTypeMeta(Label="Donation", Source=SubathonEventSource.StreamLabs, IsCurrencyDonation = true, Order =2)] StreamLabsDonation, + + [EventTypeMeta(Label="Membership", Source=SubathonEventSource.YouTube, IsMembership = true, Order =1)] YouTubeMembership, + [EventTypeMeta(Label="Gift Membership", Source=SubathonEventSource.YouTube, IsMembership = true, IsGift = true, Order =2)] YouTubeGiftMembership, + [EventTypeMeta(Label="SuperChat", Source=SubathonEventSource.YouTube, IsCurrencyDonation = true, Order =3)] YouTubeSuperChat, + [EventTypeMeta(Label="Hype Train", Source=SubathonEventSource.Twitch, IsTrain = true, HasValueConfig = false, Order = 6)] TwitchHypeTrain, // value is start, progress, end. Alt type event. Amount is level. + + [EventTypeMeta(Label="Charity Donation", Source=SubathonEventSource.Twitch, IsCurrencyDonation = true, Order = 7)] TwitchCharityDonation, + [EventTypeMeta(Label="Donation", Source=SubathonEventSource.External, IsCurrencyDonation = true, IsExternal=true, Order = 1)] ExternalDonation, + [EventTypeMeta(Label="Subscription", Source=SubathonEventSource.External, IsSubscription = true, IsExternal = true, Order = 2)] ExternalSub, + [EventTypeMeta(Label="Donation", Source=SubathonEventSource.KoFi, IsCurrencyDonation = true, IsExternal=true, Order = 1)] KoFiDonation, + [EventTypeMeta(Label="Membership", Source=SubathonEventSource.KoFi, IsMembership = true, IsExternal=true, Order = 2)] KoFiSub, + [EventTypeMeta(Label="Donation Adjustment", Source=SubathonEventSource.Command, IsCurrencyDonation = true, + IsOther = true, IsCommand=true, HasValueConfig = false, Order = 1)] DonationAdjustment, + [EventTypeMeta(Label="Bits", Source=SubathonEventSource.Blerp, IsToken = true, Order = 1)] BlerpBits, // twitch only + [EventTypeMeta(Label="Beets", Source=SubathonEventSource.Blerp, IsToken = true, IsExtension=true, Order = 2)] BlerpBeets, + [EventTypeMeta(Label="Follow", Source=SubathonEventSource.Picarto, IsFollow = true, IsExtension=true, Order = 4)] PicartoFollow, + [EventTypeMeta(Label="Subscription", Source=SubathonEventSource.Picarto, IsSubscription = true, Order = 1)] PicartoSub, + [EventTypeMeta(Label="Gift Subscription", Source=SubathonEventSource.Picarto, IsGift = true, Order = 2)] PicartoGiftSub, + [EventTypeMeta(Label="Kudos Tip", Source=SubathonEventSource.Picarto, IsToken = true, Order = 3)] PicartoTip, + [GoAffProTypeMeta(Label="GamerSupps Order", Source=SubathonEventSource.GoAffPro, IsOrder = true, Order = 1, StoreSource = GoAffProSource.GamerSupps)] GamerSuppsOrder, - UwUMarketOrder + [GoAffProTypeMeta(Label="UwUMarket Order", Source=SubathonEventSource.GoAffPro, IsOrder = true, Order = 2, StoreSource = GoAffProSource.UwUMarket)] + UwUMarketOrder, + [GoAffProTypeMeta(Label="Orchid Eight Order", Source=SubathonEventSource.GoAffPro, IsOrder = true, Order = 3, StoreSource = GoAffProSource.OrchidEight)] + OrchidEightOrder, + [GoAffProTypeMeta(Label="KatDragonz Order", Source=SubathonEventSource.GoAffPro, IsOrder = true, Order = 4, StoreSource = GoAffProSource.KatDragonz)] + KatDragonzOrder // any new must be added after the last } [ExcludeFromCodeCoverage] public static class SubathonEventTypeHelper { - private static readonly SubathonEventType[] DisabledEvents = - [ - //SubathonEventType.GamerSuppsOrder, - //SubathonEventType.UwUMarketOrder - ]; - - private static readonly SubathonEventType[] CurrencyDonationEvents = new[] + + private static EventTypeMetaAttribute? Meta(this SubathonEventType? value) { - SubathonEventType.YouTubeSuperChat, - SubathonEventType.StreamElementsDonation, - SubathonEventType.StreamLabsDonation, - SubathonEventType.TwitchCharityDonation, - SubathonEventType.ExternalDonation, - SubathonEventType.KoFiDonation, - SubathonEventType.DonationAdjustment - }; + if (!value.HasValue) return null; + var meta = EnumMetaCache.Get(value); + if (meta?.Source == SubathonEventSource.GoAffPro) + return GoAffProMeta(value); + return meta; + } - public static readonly SubathonEventType[] CheerTypes = new[] + private static GoAffProTypeMetaAttribute? GoAffProMeta(this SubathonEventType? value) { - SubathonEventType.TwitchCheer, - SubathonEventType.BlerpBeets, - SubathonEventType.BlerpBits, // needs a modifier - SubathonEventType.PicartoTip - }; + return value != null ? EnumMetaCache.Get(value) : null; + } - private static readonly SubathonEventType[] GiftTypes = new[] - { + public static bool IsCurrencyDonation(this SubathonEventType? value) + => value.Meta()?.IsCurrencyDonation == true; + + public static bool IsSubscription(this SubathonEventType? value) + => value.Meta()?.IsSubscriptionLike == true; - SubathonEventType.YouTubeGiftMembership, - SubathonEventType.TwitchGiftSub, - SubathonEventType.PicartoGiftSub, - }; + public static bool IsGift(this SubathonEventType? value) + => value.Meta()?.IsGift == true; - private static readonly SubathonEventType[] MembershipTypes = new[] - { - SubathonEventType.YouTubeMembership, - SubathonEventType.YouTubeGiftMembership, - SubathonEventType.KoFiSub - }; + public static bool IsToken(this SubathonEventType? value) + => value.Meta()?.IsToken == true; - private static readonly SubathonEventType[] SubscriptionTypes = new[] - { - SubathonEventType.TwitchSub, - SubathonEventType.TwitchGiftSub, - SubathonEventType.ExternalSub, - SubathonEventType.PicartoSub, - SubathonEventType.PicartoGiftSub - }; + public static bool IsCommand(this SubathonEventType? value) + => value.Meta()?.IsCommand == true; + + public static bool IsExternal(this SubathonEventType? value) + => value.Meta()?.IsExternal == true; + + public static bool IsRaid(this SubathonEventType? value) + => value.Meta()?.IsRaid == true; + public static bool IsFollow(this SubathonEventType? value) + => value.Meta()?.IsFollow == true; - private static readonly SubathonEventType[] ExternalTypes = new[] - { - SubathonEventType.ExternalDonation, - SubathonEventType.ExternalSub, - SubathonEventType.KoFiSub, - SubathonEventType.KoFiDonation, - }; + public static bool IsTrain(this SubathonEventType? value) => value.Meta()?.IsTrain == true; + public static bool IsOrder(this SubathonEventType? value) => value.Meta()?.IsOrder == true; + public static bool IsExtension(this SubathonEventType? value) => value.Meta()?.IsExtension == true; + public static bool IsOther(this SubathonEventType? value) + => value.Meta()?.IsOther == true; - private static readonly SubathonEventType[] NoValueConfigTypes = new[] - { - SubathonEventType.Command, - SubathonEventType.DonationAdjustment, - SubathonEventType.ExternalSub, - SubathonEventType.Unknown, - SubathonEventType.TwitchHypeTrain - }; + public static bool HasNoValueConfig(this SubathonEventType? value) => value.Meta()?.HasValueConfig == true; - private static readonly SubathonEventType[] ExtensionType = new[] - { - SubathonEventType.BlerpBeets, - SubathonEventType.BlerpBits - }; - - private static readonly SubathonEventType[] FollowTypes = new[] - { - SubathonEventType.PicartoFollow, - SubathonEventType.TwitchFollow - }; - - private static readonly SubathonEventType[] OrderTypes = new[] - { - SubathonEventType.UwUMarketOrder, - SubathonEventType.GamerSuppsOrder - }; + public static SubathonEventSource GetSource(this SubathonEventType value) => + ((SubathonEventType?)value).GetSource(); + public static SubathonEventSource GetSource(this SubathonEventType? value) + => value.Meta()?.Source ?? SubathonEventSource.Unknown; + public static string GetLabel(this SubathonEventType? value) => value.Meta()?.Label ?? value.ToString() ?? string.Empty; + + public static SubathonEventSubType? GetSubType(this SubathonEventType eventType) => GetSubType((SubathonEventType?)eventType); public static SubathonEventSubType GetSubType(this SubathonEventType? eventType) { - if (!eventType.HasValue) return SubathonEventSubType.Unknown; - if (eventType.IsGiftType()) return SubathonEventSubType.GiftSubLike; - if (eventType.IsSubOrMembershipType()) return SubathonEventSubType.SubLike; - if (eventType.IsCheerType()) return SubathonEventSubType.TokenLike; + if (eventType == null) return SubathonEventSubType.Unknown; + if (eventType.IsGift()) return SubathonEventSubType.GiftSubLike; + if (eventType.IsSubscription()) return SubathonEventSubType.SubLike; // important GiftType is above, so it has priority + if (eventType.IsToken()) return SubathonEventSubType.TokenLike; if (eventType.IsCurrencyDonation()) return SubathonEventSubType.DonationLike; - if (eventType.IsOrderType()) return SubathonEventSubType.OrderLike; - if (eventType.IsFollowType()) return SubathonEventSubType.FollowLike; - - return eventType.Value switch - { - SubathonEventType.TwitchRaid => SubathonEventSubType.RaidLike, - SubathonEventType.TwitchHypeTrain => SubathonEventSubType.TrainLike, - SubathonEventType.Command => SubathonEventSubType.CommandLike, - _ => SubathonEventSubType.Unknown - }; + if (eventType.IsOrder()) return SubathonEventSubType.OrderLike; + if (eventType.IsFollow()) return SubathonEventSubType.FollowLike; + if (eventType.IsRaid()) return SubathonEventSubType.RaidLike; + if (eventType.IsTrain()) return SubathonEventSubType.TrainLike; + if (eventType.IsCommand()) return SubathonEventSubType.CommandLike; + return SubathonEventSubType.Unknown; } - public static bool IsFollowType(this SubathonEventType? eventType) => - eventType.HasValue && FollowTypes.Contains(eventType.Value); - - public static bool IsOrderType(this SubathonEventType? eventType) => - eventType.HasValue && OrderTypes.Contains(eventType.Value); - - public static bool IsEnabled(this SubathonEventType? eventType) => - eventType.HasValue && !DisabledEvents.Contains(eventType.Value); - - public static bool IsExtensionType(this SubathonEventType? eventType) => - eventType.HasValue && ExtensionType.Contains(eventType.Value); - - public static bool IsCurrencyDonation(this SubathonEventType? eventType) => - eventType.HasValue && CurrencyDonationEvents.Contains(eventType.Value); - - public static bool IsGiftType(this SubathonEventType? eventType) => - eventType.HasValue && GiftTypes.Contains(eventType.Value); + public static string? GetTypeTrueSource(this SubathonEventType eventType) => GetTypeTrueSource((SubathonEventType?)eventType); - public static bool IsMembershipType(this SubathonEventType? eventType) => - eventType.HasValue && MembershipTypes.Contains(eventType.Value); - - public static bool IsSubscriptionType(this SubathonEventType? eventType) => - eventType.HasValue && SubscriptionTypes.Contains(eventType.Value); - - public static bool IsSubOrMembershipType(this SubathonEventType? eventType) => - eventType.IsMembershipType() || eventType.IsSubscriptionType(); - - public static bool IsExternalType(this SubathonEventType? eventType) => - eventType.HasValue && ExternalTypes.Contains(eventType.Value); - - public static bool IsCheerType(this SubathonEventType? eventType) => - eventType.HasValue && CheerTypes.Contains(eventType.Value); - - public static bool HasNoValueConfig(this SubathonEventType? eventType) => - eventType.HasValue && !NoValueConfigTypes.Contains(eventType.Value); - - public static SubathonEventSource GetSource(this SubathonEventType? eventType) { - if (!eventType.HasValue) return SubathonEventSource.Unknown; - - return eventType.Value switch - { - SubathonEventType.TwitchHypeTrain => SubathonEventSource.Twitch, - SubathonEventType.TwitchCharityDonation => SubathonEventSource.Twitch, - SubathonEventType.TwitchGiftSub => SubathonEventSource.Twitch, - SubathonEventType.TwitchRaid => SubathonEventSource.Twitch, - SubathonEventType.TwitchFollow => SubathonEventSource.Twitch, - SubathonEventType.TwitchSub => SubathonEventSource.Twitch, - SubathonEventType.TwitchCheer => SubathonEventSource.Twitch, - - SubathonEventType.BlerpBits => SubathonEventSource.Blerp, // but twitch only - SubathonEventType.BlerpBeets => SubathonEventSource.Blerp, - - SubathonEventType.StreamElementsDonation => SubathonEventSource.StreamElements, - SubathonEventType.StreamLabsDonation => SubathonEventSource.StreamLabs, - - SubathonEventType.ExternalDonation => SubathonEventSource.External, - SubathonEventType.ExternalSub => SubathonEventSource.External, - - SubathonEventType.KoFiSub => SubathonEventSource.KoFi, - SubathonEventType.KoFiDonation => SubathonEventSource.KoFi, - - SubathonEventType.Command => SubathonEventSource.Command, - - SubathonEventType.YouTubeGiftMembership => SubathonEventSource.YouTube, - SubathonEventType.YouTubeMembership => SubathonEventSource.YouTube, - SubathonEventType.YouTubeSuperChat => SubathonEventSource.YouTube, - - SubathonEventType.PicartoFollow => SubathonEventSource.Picarto, - SubathonEventType.PicartoSub => SubathonEventSource.Picarto, - SubathonEventType.PicartoGiftSub => SubathonEventSource.Picarto, - SubathonEventType.PicartoTip => SubathonEventSource.Picarto, - - SubathonEventType.DonationAdjustment => SubathonEventSource.Command, - - SubathonEventType.GamerSuppsOrder => SubathonEventSource.GoAffPro, - SubathonEventType.UwUMarketOrder => SubathonEventSource.GoAffPro, - - _ => SubathonEventSource.Unknown - }; + public static string? GetTypeTrueSource(this SubathonEventType? eventType) + { + if (eventType is null or SubathonEventType.Command) return "Manual"; + if (eventType.GetSource() == SubathonEventSource.GoAffPro) return eventType.GoAffProMeta()?.StoreSource.ToString(); + return eventType.GetSource().ToString(); } } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonSourceGroup.cs b/SubathonManager.Core/Enums/SubathonSourceGroup.cs new file mode 100644 index 0000000..5bd9a4c --- /dev/null +++ b/SubathonManager.Core/Enums/SubathonSourceGroup.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace SubathonManager.Core.Enums; + +public enum SubathonSourceGroup +{ + [EnumMeta(Description="Unknown", Label="Unknown")] + Unknown, + [EnumMeta(Description="Stream Services", Label="Stream Services")] + Stream, + [EnumMeta(Description="Stream Extensions", Label="Stream Extensions")] + StreamExtension, + // [Description("Chat Extension")] + // ChatExtension, + [EnumMeta(Description="Misc", Label="Misc")] + Misc, + [EnumMeta(Description="External Services", Label="External Services")] + ExternalService, + [EnumMeta(Label="")] + UseSource +} diff --git a/SubathonManager.Core/Enums/WidgetVariableType.cs b/SubathonManager.Core/Enums/WidgetVariableType.cs index 20b383c..da66514 100644 --- a/SubathonManager.Core/Enums/WidgetVariableType.cs +++ b/SubathonManager.Core/Enums/WidgetVariableType.cs @@ -18,7 +18,12 @@ public enum WidgetVariableType SoundFile, EventSubTypeList, EventSubTypeSelect, - FolderPath + FolderPath, + OrderEventTypeList, + TokenEventTypeList, + SubEventTypeList, + FollowEventTypeList, + DonationEventTypeList } [ExcludeFromCodeCoverage] @@ -39,6 +44,17 @@ public static bool IsFileVariable(this WidgetVariableType? varType) => public static bool IsEnumVariable(this WidgetVariableType? varType) => varType.HasValue && GetClsSingleType(varType.Value).IsEnum; + public static List GetFilteredEventTypes(this WidgetVariableType varType) => varType switch + { + WidgetVariableType.OrderEventTypeList => SubathonEventSubTypeHelper.OrderEventTypes, + WidgetVariableType.SubEventTypeList => SubathonEventSubTypeHelper.SubEventTypes, + WidgetVariableType.TokenEventTypeList => SubathonEventSubTypeHelper.TokenEventTypes, + WidgetVariableType.FollowEventTypeList => SubathonEventSubTypeHelper.FollowEventTypes, + WidgetVariableType.DonationEventTypeList => SubathonEventSubTypeHelper.DonationEventTypes, + WidgetVariableType.EventTypeList => Enum.GetValues().ToList(), + _ => [] + }; + public static Type GetClsSingleType(this WidgetVariableType varType) => varType switch { WidgetVariableType.Int => typeof(int), @@ -60,7 +76,13 @@ public static bool IsEnumVariable(this WidgetVariableType? varType) => WidgetVariableType.SoundFile => typeof(string), WidgetVariableType.FolderPath => typeof(string), - _ => throw new ArgumentOutOfRangeException(nameof(varType), varType, null) + WidgetVariableType.OrderEventTypeList => typeof(SubathonEventType), + WidgetVariableType.TokenEventTypeList => typeof(SubathonEventType), + WidgetVariableType.SubEventTypeList => typeof(SubathonEventType), + WidgetVariableType.FollowEventTypeList => typeof(SubathonEventType), + WidgetVariableType.DonationEventTypeList => typeof(SubathonEventType), + + _ => typeof(string) }; } @@ -70,8 +92,10 @@ public enum WidgetCssVariableType String, Color, Alignment, - Size - + Size, + Float, + Int, + Opacity } [ExcludeFromCodeCoverage] diff --git a/SubathonManager.Core/Events/IntegrationEvents.cs b/SubathonManager.Core/Events/IntegrationEvents.cs index 0f9ab94..a4691ae 100644 --- a/SubathonManager.Core/Events/IntegrationEvents.cs +++ b/SubathonManager.Core/Events/IntegrationEvents.cs @@ -1,15 +1,17 @@ using System.Diagnostics.CodeAnalysis; using SubathonManager.Core.Enums; +using SubathonManager.Core.Objects; namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] public static class IntegrationEvents { - public static event Action? ConnectionUpdated; // status, src, acc name, service + public static event Action? ConnectionUpdated; // status, src, acc name, service - public static void RaiseConnectionUpdate(bool status, SubathonEventSource source, string name, string service) + public static void RaiseConnectionUpdate(IntegrationConnection connection) { - ConnectionUpdated?.Invoke(status, source, name, service); + Utils.UpdateConnection(connection); + ConnectionUpdated?.Invoke(connection); } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/SubathonEvents.cs b/SubathonManager.Core/Events/SubathonEvents.cs index b1426c7..ce6b2b0 100644 --- a/SubathonManager.Core/Events/SubathonEvents.cs +++ b/SubathonManager.Core/Events/SubathonEvents.cs @@ -1,6 +1,7 @@ using SubathonManager.Core.Models; using System.Diagnostics.CodeAnalysis; using SubathonManager.Core.Enums; +using SubathonManager.Core.Objects; namespace SubathonManager.Core.Events; @@ -19,6 +20,13 @@ public static class SubathonEvents public static event Action? SubathonValueConfigUpdatedRemote; public static event Action>? SubathonValuesPatched; + + public static event Action? SubathonTotalsUpdated; + + public static void RaiseSubathonTotalsUpdated(SubathonTotals totals) + { + SubathonTotalsUpdated?.Invoke(totals); + } public static void RaiseSubathonValuesPatched(List values) { diff --git a/SubathonManager.Core/Events/WebServerEvents.cs b/SubathonManager.Core/Events/WebServerEvents.cs index 04b0a7f..4379202 100644 --- a/SubathonManager.Core/Events/WebServerEvents.cs +++ b/SubathonManager.Core/Events/WebServerEvents.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using SubathonManager.Core.Enums; +using SubathonManager.Core.Objects; namespace SubathonManager.Core.Events; @@ -17,9 +18,14 @@ public static void RaiseWebServerStatusChange(bool status) public static void RaiseWebSocketIntegrationSourceChange(string integrationSource, bool status) { WebSocketIntegrationSourceChange?.Invoke(integrationSource, status); - if (Enum.TryParse(integrationSource, out SubathonEventSource subathonEventSource)) + if (!Enum.TryParse(integrationSource, out SubathonEventSource subathonEventSource)) return; + IntegrationConnection conn = new IntegrationConnection { - IntegrationEvents.RaiseConnectionUpdate(status, subathonEventSource, "External", "Socket"); - } + Name = "External", + Status = status, + Source = subathonEventSource, + Service = "Socket" + }; + IntegrationEvents.RaiseConnectionUpdate(conn); } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/WidgetEvents.cs b/SubathonManager.Core/Events/WidgetEvents.cs index edb056c..f0abbdb 100644 --- a/SubathonManager.Core/Events/WidgetEvents.cs +++ b/SubathonManager.Core/Events/WidgetEvents.cs @@ -7,6 +7,7 @@ public static class WidgetEvents { public static event Action? WidgetPositionUpdated; public static event Action? WidgetScaleUpdated; + public static event Action? WidgetSizeUpdated; public static event Action? SelectEditorWidget; public static void RaisePositionUpdated(Widget widget) @@ -23,4 +24,9 @@ public static void RaiseScaleUpdated(Widget widget) { WidgetScaleUpdated?.Invoke(widget); } + + public static void RaiseSizeUpdated(Widget widget) + { + WidgetSizeUpdated?.Invoke(widget); + } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonEvent.cs b/SubathonManager.Core/Models/SubathonEvent.cs index b583ee6..84971f6 100644 --- a/SubathonManager.Core/Models/SubathonEvent.cs +++ b/SubathonManager.Core/Models/SubathonEvent.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Core.Models; @@ -49,9 +50,14 @@ public class SubathonEvent public bool WasReversed { get; set; } = false; // do we want to later finetune power hour to be for selectable events? + private int GetAmountMultiplier() + { + return EventType.IsOrder() ? 1 : Amount; + } + public double GetFinalSecondsValue() => Math.Ceiling(GetFinalSecondsValueRaw()); - public double GetFinalSecondsValueRaw() => Amount * SecondsValue * (Source == SubathonEventSource.Command ? 1 : MultiplierSeconds) ?? 0; - public double GetFinalPointsValue() => Math.Floor(Amount * PointsValue * (Source == SubathonEventSource.Command ? 1 : Math.Round(MultiplierPoints+0.001)) ?? 0); + public double GetFinalSecondsValueRaw() => GetAmountMultiplier() * SecondsValue * (Source == SubathonEventSource.Command ? 1 : MultiplierSeconds) ?? 0; + public double GetFinalPointsValue() => Math.Floor(GetAmountMultiplier() * PointsValue * (Source == SubathonEventSource.Command ? 1 : Math.Round(MultiplierPoints+0.001)) ?? 0); public SubathonEvent ShallowClone() { diff --git a/SubathonManager.Core/Objects/IntegrationConnection.cs b/SubathonManager.Core/Objects/IntegrationConnection.cs new file mode 100644 index 0000000..b5bcc76 --- /dev/null +++ b/SubathonManager.Core/Objects/IntegrationConnection.cs @@ -0,0 +1,13 @@ +using SubathonManager.Core.Enums; + +namespace SubathonManager.Core.Objects; + +public class IntegrationConnection +{ + public SubathonEventSource Source { get; init; } + public string Service { get; init; } = ""; + public string Name { get; init; } = ""; + public bool Status { get; init; } + + public override string ToString() => $"[{Source}:{Service}] [{Name}] Connected: {Status}"; +} \ No newline at end of file diff --git a/SubathonManager.Core/Objects/SubathonTotals.cs b/SubathonManager.Core/Objects/SubathonTotals.cs new file mode 100644 index 0000000..2a54d9a --- /dev/null +++ b/SubathonManager.Core/Objects/SubathonTotals.cs @@ -0,0 +1,38 @@ +using SubathonManager.Core.Enums; + +namespace SubathonManager.Core.Objects; + +public class SubathonTotals +{ + public double MoneySum { get; init; } = 0; + public string? Currency { get; init; } = "USD"; + + public int SubLikeTotal { get; init; } = 0; + public Dictionary SubLikeByEvent { get; init; } = new(); + + public long TokenLikeTotal { get; init; } = 0; + public Dictionary TokenLikeByEvent { get; init; } = new(); + + public Dictionary OrderCountByType { get; init; } = new(); + public Dictionary OrderItemsCountByType { get; init; } = new(); + + public int FollowLikeTotal { get; init; } = 0; + public Dictionary FollowLikeByEvent { get; init; } = new(); + + public SubathonSimulatedTotals Simulated { get; init; } = new(); +} + +public class SubathonSimulatedTotals +{ + public int SubLikeTotal { get; init; } = 0; + public Dictionary SubLikeByEvent { get; init; } = new(); + + public long TokenLikeTotal { get; init; } = 0; + public Dictionary TokenLikeByEvent { get; init; } = new(); + + public Dictionary OrderCountByType { get; init; } = new(); + public Dictionary OrderItemsCountByType { get; init; } = new(); + + public int FollowLikeTotal { get; init; } = 0; + public Dictionary FollowLikeByEvent { get; init; } = new(); +} \ No newline at end of file diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index a692b9b..ef6a975 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -1,16 +1,46 @@ -using System.Runtime.InteropServices; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; using System.Text.RegularExpressions; using System.Security.Cryptography; using System.Text; using SubathonManager.Core.Enums; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Objects; namespace SubathonManager.Core; public static class Utils { - public static readonly Dictionary DonationSettings = new Dictionary(); + public static readonly Dictionary DonationSettings = new(); + private static readonly ConcurrentDictionary<(SubathonEventSource Source, string Service), IntegrationConnection> ConnectionDetails = new(); + + public static IntegrationConnection GetConnection(SubathonEventSource source, string service) + { + ConnectionDetails.TryGetValue((source, service), out var conn); + if (conn != null) return conn; + conn = new IntegrationConnection() + { + Source = source, + Service = service, + Name = "", + Status = false + }; + UpdateConnection(conn); + + return conn; + } + + public static void UpdateConnection(IntegrationConnection connection) + { + var key = (connection.Source, connection.Service); + + ConnectionDetails.AddOrUpdate( + key, + connection, + (_, _) => connection + ); + } public static TimeSpan ParseDurationString(string input) { @@ -170,7 +200,7 @@ public static string EscapeCsv(string? value) public static (bool, double) GetAltCurrencyUseAsDonation(IConfig config, SubathonEventType? eventType) { double modifier = 1; - if (!eventType.IsCheerType()) + if (!eventType.IsToken()) return (false, 1); if (eventType != SubathonEventType.TwitchCheer && eventType != SubathonEventType.PicartoTip) { diff --git a/SubathonManager.Data/DbContext.cs b/SubathonManager.Data/DbContext.cs index 7fb7612..9ce3063 100644 --- a/SubathonManager.Data/DbContext.cs +++ b/SubathonManager.Data/DbContext.cs @@ -4,6 +4,7 @@ using SubathonManager.Core.Models; using SubathonManager.Core.Enums; using SubathonManager.Core; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data { @@ -167,7 +168,7 @@ public static async Task> GetSubathonCurrencyEvents(AppDbCon bool includeBits = Utils.DonationSettings.TryGetValue("BitsLikeAsDonation", out bool bitslike) && bitslike ; List orderTypesToInclude = new List(); - foreach (var goAffProSource in Enum.GetNames()) + foreach (var goAffProSource in Enum.GetValues().Where(ga => ga != GoAffProSource.Unknown && !ga.IsDisabled())) { bool asDonation = Utils.DonationSettings.TryGetValue($"{goAffProSource}", out bool donation) && donation; if (asDonation && Enum.TryParse($"{goAffProSource}Order", out SubathonEventType eventType)) @@ -176,10 +177,8 @@ public static async Task> GetSubathonCurrencyEvents(AppDbCon events = events.Where(e => e.EventType != null && (e.EventType.IsCurrencyDonation() || - (e.EventType.IsOrderType() && orderTypesToInclude.Contains((SubathonEventType)e.EventType)) || - (includeBits && - SubathonEventTypeHelper.CheerTypes.Contains - ((SubathonEventType)e.EventType)))).ToList(); + (e.EventType.IsOrder() && orderTypesToInclude.Contains((SubathonEventType)e.EventType)) || + (includeBits && e.EventType.IsToken()))).ToList(); return events; } @@ -314,7 +313,9 @@ public static void SeedDefaultValues(AppDbContext db) // assuming defaults for order types are by Dollar, we set at 12s new SubathonValue { EventType = SubathonEventType.GamerSuppsOrder, Seconds = 12 }, new SubathonValue { EventType = SubathonEventType.UwUMarketOrder, Seconds = 12 }, - + new SubathonValue { EventType = SubathonEventType.OrchidEightOrder, Seconds = 12 }, + new SubathonValue { EventType = SubathonEventType.KatDragonzOrder, Seconds = 12 }, + new SubathonValue { EventType = SubathonEventType.ExternalSub, Meta = "DEFAULT", Seconds = 60, Points = 1} }; foreach (var def in defaults) diff --git a/SubathonManager.Data/OverlayPorter.cs b/SubathonManager.Data/OverlayPorter.cs index dd24e84..82bf952 100644 --- a/SubathonManager.Data/OverlayPorter.cs +++ b/SubathonManager.Data/OverlayPorter.cs @@ -81,6 +81,7 @@ private static ExportPlan BuildExportPlan(List widgets) var widget = widgets[i]; string widgetRoot = widgetRoots[i]; string zipWidgetRoot = zipRoots[i]; + var baseFolder = GetWidgetBaseFolder(zipWidgetRoot); plan.WidgetFolderMap[widget.Id] = zipWidgetRoot; @@ -109,14 +110,14 @@ private static ExportPlan BuildExportPlan(List widgets) foreach (var file in Directory.EnumerateFiles(jsVar.Value, "*", SearchOption.AllDirectories)) { string relative = Path.GetRelativePath(jsVar.Value, file).Replace('\\', '/'); - plan.FileCopies.Add((file, $"{zipWidgetRoot}/{ExternalFolder}/{varFolderName}/{relative}")); + plan.FileCopies.Add((file, $"{baseFolder}/{ExternalFolder}/{varFolderName}/{relative}")); } SetRewrite(plan.VariableRewrites, widget.Id, jsVar.Name, $"./{ExternalFolder}/{varFolderName}"); } else if (!isFolderType && File.Exists(jsVar.Value)) { string fileName = Path.GetFileName(jsVar.Value); - plan.FileCopies.Add((jsVar.Value, $"{zipWidgetRoot}/{ExternalFolder}/{fileName}")); + plan.FileCopies.Add((jsVar.Value, $"{baseFolder}/{ExternalFolder}/{fileName}")); SetRewrite(plan.VariableRewrites, widget.Id, jsVar.Name, $"./{ExternalFolder}/{fileName}"); } } @@ -362,6 +363,12 @@ private static void SetRewrite(Dictionary> rewr inner[varName] = value; } + private static string GetWidgetBaseFolder(string zipWidgetRoot) + { + int lastSlash = zipWidgetRoot.LastIndexOf('/'); + return lastSlash > 0 ? zipWidgetRoot[..lastSlash] : zipWidgetRoot; + } + public static List GetZipWidgetRoots(List absoluteFolderPaths) { if (absoluteFolderPaths.Count == 0) return new(); @@ -370,51 +377,60 @@ public static List GetZipWidgetRoots(List absoluteFolderPaths) .Select(p => p.Replace('\\', '/').TrimEnd('/').Split('/', StringSplitOptions.RemoveEmptyEntries)) .ToList(); - var ancestors = segmentSets.Select(s => s[..^1]).ToList(); + static bool IsPrefix(string[] parent, string[] child) + { + if (parent.Length > child.Length) return false; + return !parent.Where((t, i) => !string.Equals(t, child[i], StringComparison.OrdinalIgnoreCase)).Any(); + } + + var roots = new List(segmentSets.Count); - int commonLength = ancestors[0].Length; - for (int i = 1; i < ancestors.Count; i++) + foreach (var current in segmentSets) { - int shared = 0; - int max = Math.Min(commonLength, ancestors[i].Length); - for (int j = 0; j < max; j++) + string[]? bestRoot = null; + + foreach (var candidate in segmentSets) { - if (string.Equals(ancestors[0][j], ancestors[i][j], StringComparison.OrdinalIgnoreCase)) - shared++; - else - break; + if (ReferenceEquals(candidate, current)) continue; + + if (IsPrefix(candidate, current)) + { + if (bestRoot == null || candidate.Length > bestRoot.Length) + bestRoot = candidate; + } } - commonLength = shared; + + roots.Add(bestRoot ?? current); } - var results = new List(absoluteFolderPaths.Count); - foreach (var segment in segmentSets) + var results = new List(segmentSets.Count); + + for (int i = 0; i < segmentSets.Count; i++) { + var segment = segmentSets[i]; + var root = roots[i]; + string leaf = SanitizeName(segment[^1]); - var unique = segment[..^1][commonLength..]; var sb = new StringBuilder("widgets"); - if (unique.Length > 0) - { - sb.Append('/'); - sb.Append(HashSegment(unique[0])); - for (int j = 1; j < unique.Length; j++) - { - sb.Append('/'); - sb.Append(SanitizeName(unique[j])); - } - } - else + + var parentParts = root.SkipLast(1).ToArray(); + + string bucketSource = parentParts.Length > 0 + ? string.Join("/", parentParts).ToLowerInvariant() + : root[^1].ToLowerInvariant(); + + sb.Append('/'); + sb.Append(HashSegment(bucketSource)); + + int start = root.Length - 1; + + for (int j = start; j < segment.Length; j++) { - string bucketSource = commonLength > 0 - ? ancestors[0][commonLength - 1] - : leaf; sb.Append('/'); - sb.Append(HashSegment(bucketSource)); + sb.Append(SanitizeName(segment[j])); } - sb.Append('/'); - sb.Append(leaf); results.Add(sb.ToString()); } diff --git a/SubathonManager.Data/WidgetEntityHelper.cs b/SubathonManager.Data/WidgetEntityHelper.cs index 9706a78..8e8f57c 100644 --- a/SubathonManager.Data/WidgetEntityHelper.cs +++ b/SubathonManager.Data/WidgetEntityHelper.cs @@ -217,44 +217,43 @@ private Dictionary GetMetaData(string html) { } return result; } + + private async Task<(Widget?, DbContext?)> GetWidgetForUpdate(string widgetId, Dictionary data) + { + if (data.Count == 0 || !Guid.TryParse(widgetId, out var widgetGuid)) return (null, null); + var db = await _factory.CreateDbContextAsync(); + var widget = await db.Widgets.FirstOrDefaultAsync(w => w.Id == widgetGuid); + return widget == null ? (null, db) : (widget, db); + } public async Task UpdateWidgetScale(string widgetId, Dictionary data) { - if (!data.Any()) return false; + (Widget?, DbContext?) result = await GetWidgetForUpdate(widgetId, data); + var widget = result.Item1; + await using var db = result.Item2; + if (widget == null || db == null) return false; - if (Guid.TryParse(widgetId, out var widgetGuid)) - { - await using var db = await _factory.CreateDbContextAsync(); - var widget = await db.Widgets.FirstOrDefaultAsync(w => w.Id == widgetGuid); - if (widget != null) - { - float origX = widget.X; - float origY = widget.Y; - if (data.TryGetValue("scaleX", out var sxElem) && sxElem.TryGetSingle(out var sx)) widget.ScaleX = sx; - if (data.TryGetValue("scaleY", out var syElem) && syElem.TryGetSingle(out var sy)) widget.ScaleY = sy; - if (data.TryGetValue("x", out var xElem) && xElem.TryGetSingle(out var x)) widget.X = x; - if (data.TryGetValue("y", out var yElem) && yElem.TryGetSingle(out var y)) widget.Y = y; + float origX = widget.X; + float origY = widget.Y; + if (data.TryGetValue("scaleX", out var sxElem) && sxElem.TryGetSingle(out var sx)) widget.ScaleX = sx; + if (data.TryGetValue("scaleY", out var syElem) && syElem.TryGetSingle(out var sy)) widget.ScaleY = sy; + if (data.TryGetValue("x", out var xElem) && xElem.TryGetSingle(out var x)) widget.X = x; + if (data.TryGetValue("y", out var yElem) && yElem.TryGetSingle(out var y)) widget.Y = y; - await db.SaveChangesAsync(); - WidgetEvents.RaiseScaleUpdated(widget); - if (!origX.Equals(widget.X) || !origY.Equals(widget.Y)) - WidgetEvents.RaisePositionUpdated(widget); - await db.Entry(widget).ReloadAsync(); - return true; - } - } - return false; + await db.SaveChangesAsync(); + WidgetEvents.RaiseScaleUpdated(widget); + if (!origX.Equals(widget.X) || !origY.Equals(widget.Y)) + WidgetEvents.RaisePositionUpdated(widget); + await db.Entry(widget).ReloadAsync(); + return true; } public async Task UpdateWidgetPosition(string widgetId, Dictionary data) { - if (!data.Any()) return false; - - if (!Guid.TryParse(widgetId, out var widgetGuid)) return false; - - await using var db = await _factory.CreateDbContextAsync(); - var widget = await db.Widgets.FirstOrDefaultAsync(w => w.Id == widgetGuid); - if (widget == null) return false; + (Widget?, DbContext?) result = await GetWidgetForUpdate(widgetId, data); + var widget = result.Item1; + await using var db = result.Item2; + if (widget == null || db == null) return false; if (data.TryGetValue("x", out var xElem) && xElem.TryGetSingle(out var x)) widget.X = x; if (data.TryGetValue("y", out var yElem) && yElem.TryGetSingle(out var y)) widget.Y = y; @@ -266,4 +265,22 @@ public async Task UpdateWidgetPosition(string widgetId, Dictionary UpdateWidgetDimensions(string widgetId, Dictionary data) + { + (Widget?, DbContext?) result = await GetWidgetForUpdate(widgetId, data); + var widget = result.Item1; + await using var db = result.Item2; + if (widget == null || db == null) return false; + + if (data.TryGetValue("width", out var wEl) && wEl.TryGetInt32(out int w)) widget.Width = w; + if (data.TryGetValue("height", out var hEl) && hEl.TryGetInt32(out int h)) widget.Height = h; + if (data.TryGetValue("x", out var xEl) && xEl.TryGetSingle(out float x)) widget.X = x; + if (data.TryGetValue("y", out var yEl) && yEl.TryGetSingle(out float y)) widget.Y = y; + + await db.SaveChangesAsync(); + WidgetEvents.RaiseSizeUpdated(widget); + await db.Entry(widget).ReloadAsync(); + return true; + } } \ No newline at end of file diff --git a/SubathonManager.Integration/ExternalEventService.cs b/SubathonManager.Integration/ExternalEventService.cs index 6b1ec65..5f225d4 100644 --- a/SubathonManager.Integration/ExternalEventService.cs +++ b/SubathonManager.Integration/ExternalEventService.cs @@ -3,6 +3,7 @@ using SubathonManager.Core.Models; using SubathonManager.Core.Events; using SubathonManager.Services; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; @@ -42,8 +43,8 @@ public static bool ProcessExternalSub(Dictionary data) data.TryGetValue("value", out JsonElement elemValue); string value = elemValue.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(elemValue.GetString()) - ? elemValue.GetString()! : "External"; - if (string.IsNullOrWhiteSpace(value)) value = "External"; + ? elemValue.GetString()! : "DEFAULT"; + if (string.IsNullOrWhiteSpace(value)) value = "DEFAULT"; data.TryGetValue("amount", out JsonElement elemAmount); @@ -57,10 +58,13 @@ public static bool ProcessExternalSub(Dictionary data) { data.TryGetValue("seconds", out JsonElement elemSeconds); data.TryGetValue("points", out JsonElement elemPoints); - if (elemSeconds.ValueKind != JsonValueKind.Number || elemPoints.ValueKind != JsonValueKind.Number) - return false; - subathonEvent.SecondsValue = elemSeconds.GetDouble(); - subathonEvent.PointsValue = elemPoints.GetInt16(); + if (elemSeconds.ValueKind == JsonValueKind.Number) + { + subathonEvent.SecondsValue = elemSeconds.GetDouble(); + } + if (elemPoints.ValueKind == JsonValueKind.Number) { + subathonEvent.PointsValue = elemPoints.GetInt16(); + } } subathonEvent.Amount = elemAmount.ValueKind == JsonValueKind.Number ? elemAmount.GetInt16() : 1; diff --git a/SubathonManager.Integration/GoAffProService.cs b/SubathonManager.Integration/GoAffProService.cs index d53a017..95e4880 100644 --- a/SubathonManager.Integration/GoAffProService.cs +++ b/SubathonManager.Integration/GoAffProService.cs @@ -10,6 +10,9 @@ using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; + +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; @@ -23,24 +26,9 @@ public class GoAffProService(ILogger? logger, IConfig config) : internal Uri Endpoint = new Uri("https://api.goaffpro.com/v1/", UriKind.Absolute); internal int MaxRetries = 20; - - private static readonly Dictionary SiteMapping = new() // supported sites - { - { 165328, GoAffProSource.GamerSupps }, - { 132230, GoAffProSource.UwUMarket } - }; - private static readonly Dictionary ReverseSiteMapping = - SiteMapping.ToDictionary(x => x.Value, x => x.Key); - - private HashSet _siteIds = new(); - public static readonly Dictionary OrderMapping = new() - { - { GoAffProSource.GamerSupps, SubathonEventType.GamerSuppsOrder}, - { GoAffProSource.UwUMarket, SubathonEventType.UwUMarketOrder} - }; + private HashSet _siteIds = new(); - public async Task StartAsync(CancellationToken ct = default) { await StopAsync(ct); @@ -63,8 +51,14 @@ public async Task StartAsync(CancellationToken ct = default) email: email, password: password, cancellationToken: ct); - IntegrationEvents.RaiseConnectionUpdate(true, SubathonEventSource.GoAffPro, - "", nameof(SubathonEventSource.GoAffPro)); + IntegrationConnection conn = new IntegrationConnection + { + Name = "", + Status = true, + Source = SubathonEventSource.GoAffPro, + Service = nameof(SubathonEventSource.GoAffPro) + }; + IntegrationEvents.RaiseConnectionUpdate(conn); } catch (Exception e) { @@ -100,17 +94,33 @@ public async Task StartAsync(CancellationToken ct = default) foreach (var site in sitesResponse.Sites.Where(site => site is { Id: not null, Status: UserSite_status.Approved })) { - if (!SiteMapping.ContainsKey(site.Id!.Value) || !_siteIds.Add(site!.Id.Value)) continue; + if (!GoAffProSourceeHelper.TryGetSource(site.Id!.Value, out GoAffProSource source)) + { + logger?.LogInformation("[GoAffPro] Site {Id} ({Name}) detected on account, but not integrated. Please create an integration request if you would like it supported", site.Id!.Value, site.Name); + continue; + } + if (source == GoAffProSource.Unknown || source.IsDisabled()) continue; + if (!_siteIds.Add(site.Id.Value)) continue; string currency = !string.IsNullOrWhiteSpace(site.Currency) ? site.Currency : "USD"; - - IntegrationEvents.RaiseConnectionUpdate(true, SubathonEventSource.GoAffPro, - currency, SiteMapping[(int)site.Id].ToString()); + + IntegrationConnection conn = new IntegrationConnection + { + Name = currency, + Status = true, + Source = SubathonEventSource.GoAffPro, + Service = source.ToString() + }; + IntegrationEvents.RaiseConnectionUpdate(conn); } _detectorCts = new CancellationTokenSource(); - _client.OrderObserverStartTime = DateTimeOffset.UtcNow; + if (!int.TryParse(config.Get(_configSection, "DaysOffset", "0"), out var daysOffset)) daysOffset = 0; + + _client.OrderObserverStartTime = DateTimeOffset.UtcNow - TimeSpan.FromDays(daysOffset); + logger?.LogInformation("[GoAffPro] Started GoAffPro service with {Count} connected sites", _siteIds.Count); _ = Task.Run(async() => { + logger?.LogInformation("[GoAffPro] GoAffPro is now polling for orders..."); await foreach (var order in _client.NewOrdersAsync( pollingInterval: TimeSpan.FromSeconds(30), pageSize: 100, @@ -118,6 +128,7 @@ public async Task StartAsync(CancellationToken ct = default) { HandleOrder(order); } + logger?.LogInformation("[GoAffPro] GoAffPro polling finished"); }, _detectorCts.Token); } @@ -127,7 +138,7 @@ public void SimulateOrder(decimal total, int itemCount, decimal commissionTotal, // id is meant to be a long but w/e UserOrderFeedItem order = new UserOrderFeedItem(); - int idInt = ReverseSiteMapping.TryGetValue(affilStore, out var idParse) ? idParse : int.MaxValue; + int idInt = affilStore.TryGetSiteId(out var idParse) ? idParse : int.MaxValue; order.SiteId = new UserOrderFeedItem.UserOrderFeedItem_site_id() { Integer = idInt }; order.Id = new UserOrderFeedItem.UserOrderFeedItem_id() { String = id}; order.Number = "SIMULATED"; @@ -177,8 +188,9 @@ private void HandleOrder(UserOrderFeedItem order) } var site = order.SiteId!.Integer; - if (site == null || !SiteMapping.TryGetValue((int)site, out GoAffProSource source)) return; - + if (site == null || !GoAffProSourceeHelper.TryGetSource((int)site, out GoAffProSource source)) return; + if (source == GoAffProSource.Unknown || source.IsDisabled()) return; + // we will listen for these sites regardless in orders, but will ignore if not enabled. var enabled = config.GetBool(_configSection, $"{source}.Enabled", true); if (!enabled) return; @@ -193,7 +205,13 @@ private void HandleOrder(UserOrderFeedItem order) GoAffProModes.Order => "order", _ => order.Currency }; - + int itemCount = 0; + foreach (var item in order.LineItems) + { + itemCount += item.Quantity ?? 0; + itemCount -= item.RefundQuantity ?? 0; + } + ev.Amount = itemCount; switch (sourceMode) { case GoAffProModes.Dollar: @@ -204,20 +222,13 @@ private void HandleOrder(UserOrderFeedItem order) break; default: { - int itemCount = 0; - foreach (var item in order.LineItems) - { - itemCount += item.Quantity ?? 0; - itemCount -= item.RefundQuantity ?? 0; - } - ev.Value = $"{itemCount}"; break; } } ev.SecondaryValue = $"{order.Commission}|{order.Currency}"; - ev.EventType = OrderMapping.GetValueOrDefault(source, SubathonEventType.Unknown); + ev.EventType = source.GetOrderEvent(); if (ev.EventType == SubathonEventType.Unknown) return; ev.User = $"New {source}"; @@ -235,12 +246,25 @@ private void HandleOrder(UserOrderFeedItem order) public Task StopAsync(CancellationToken ct = default) { foreach (var integ in Enum.GetNames()) + { + IntegrationConnection conn = new IntegrationConnection + { + Name = "", + Status = false, + Source = SubathonEventSource.GoAffPro, + Service = integ + }; + IntegrationEvents.RaiseConnectionUpdate(conn); + } + + IntegrationConnection connection = new IntegrationConnection { - IntegrationEvents.RaiseConnectionUpdate(false, SubathonEventSource.GoAffPro, - "", integ); - } - IntegrationEvents.RaiseConnectionUpdate(false, SubathonEventSource.GoAffPro, - "", nameof(SubathonEventSource.GoAffPro)); + Name = "", + Status = false, + Source = SubathonEventSource.GoAffPro, + Service = nameof(SubathonEventSource.GoAffPro) + }; + IntegrationEvents.RaiseConnectionUpdate(connection); if (_client != null && _detectorCts is { IsCancellationRequested: false }) _detectorCts.Cancel(); diff --git a/SubathonManager.Integration/PicartoService.cs b/SubathonManager.Integration/PicartoService.cs index 8130d41..69c18a3 100644 --- a/SubathonManager.Integration/PicartoService.cs +++ b/SubathonManager.Integration/PicartoService.cs @@ -10,6 +10,7 @@ using SubathonManager.Services; using System.Diagnostics.CodeAnalysis; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Objects; namespace SubathonManager.Integration; @@ -66,8 +67,20 @@ public async Task StartupServiceAsync(CancellationToken ct = default) if (string.IsNullOrWhiteSpace(_picartoUsername)) return; - IntegrationEvents.RaiseConnectionUpdate(false, SubathonEventSource.Picarto, _picartoUsername, "Chat"); - IntegrationEvents.RaiseConnectionUpdate(false, SubathonEventSource.Picarto, _picartoUsername, "Alerts"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Picarto, + Service = "Chat", + Name = _picartoUsername, + Status = false + }); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Picarto, + Service = "Alerts", + Name = _picartoUsername, + Status = false + }); _logger?.LogInformation("Picarto Service Starting for " + _picartoUsername); Opts.Channel = _picartoUsername; @@ -179,8 +192,14 @@ private void OnConnect(object? sender, PicartoWebSocketConnectedEventArgs args) _chatConnected = true; _chatReconnect.Reset(); _chatReconnect.Cts?.Cancel(); - IntegrationEvents.RaiseConnectionUpdate( - true, SubathonEventSource.Picarto, _picartoUsername ?? "", "Chat"); + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Picarto, + Service = "Chat", + Name = _picartoUsername ?? "", + Status = _chatConnected + }); } else if (sender is PicartoEventsClient) @@ -188,8 +207,13 @@ private void OnConnect(object? sender, PicartoWebSocketConnectedEventArgs args) _eventsConnected = true; _eventsReconnect.Reset(); _eventsReconnect.Cts?.Cancel(); - IntegrationEvents.RaiseConnectionUpdate( - _eventsConnected, SubathonEventSource.Picarto, _picartoUsername ?? "", "Alerts"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Picarto, + Service = "Alerts", + Name = _picartoUsername ?? "", + Status = _eventsConnected + }); } } @@ -272,8 +296,14 @@ private void OnDisconnect(object? sender, PicartoWebSocketDisconnectedEventArgs if (sender is PicartoChatClient) { _chatConnected = false; - IntegrationEvents.RaiseConnectionUpdate( - _chatConnected, SubathonEventSource.Picarto, _picartoUsername ?? "", "Chat"); + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Picarto, + Service = "Chat", + Name = _picartoUsername ?? "", + Status = _chatConnected + }); if (shouldReconnect && sender is PicartoWebSocketConnection ws) { @@ -287,8 +317,13 @@ private void OnDisconnect(object? sender, PicartoWebSocketDisconnectedEventArgs else if (sender is PicartoEventsClient) { _eventsConnected = false; - IntegrationEvents.RaiseConnectionUpdate( - _eventsConnected, SubathonEventSource.Picarto, _picartoUsername ?? "", "Alerts"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Picarto, + Service = "Alerts", + Name = _picartoUsername ?? "", + Status = _eventsConnected + }); if (shouldReconnect && sender is PicartoWebSocketConnection ws) { diff --git a/SubathonManager.Integration/StreamElementsService.cs b/SubathonManager.Integration/StreamElementsService.cs index 6d45951..1dfe6a8 100644 --- a/SubathonManager.Integration/StreamElementsService.cs +++ b/SubathonManager.Integration/StreamElementsService.cs @@ -8,6 +8,7 @@ using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; namespace SubathonManager.Integration; @@ -97,8 +98,14 @@ private void _OnDisconnected(object? sender, EventArgs e) { _logger?.LogWarning("[StreamElementsService] Disconnected"); Connected = false; - - IntegrationEvents.RaiseConnectionUpdate(Connected, SubathonEventSource.StreamElements, "User", "Socket"); + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.StreamElements, + Service = "Socket", + Name = "User", + Status = Connected + }); if (_hasAuthError) return; _ = Task.Run(ReconnectWithBackoffAsync); @@ -187,8 +194,14 @@ private void _OnAuthenticated(object? sender, Authenticated e) _hasAuthError = false; _reconnectState.Cts?.Cancel(); - _reconnectState.Reset(); - IntegrationEvents.RaiseConnectionUpdate(Connected, SubathonEventSource.StreamElements, "User", "Socket"); + _reconnectState.Reset(); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.StreamElements, + Service = "Socket", + Name = "User", + Status = Connected + }); } private void _OnAuthenticateError(object? sender, EventArgs e) @@ -196,8 +209,14 @@ private void _OnAuthenticateError(object? sender, EventArgs e) _logger?.LogError("[StreamElementsService] Authentication Error"); Connected = false; _hasAuthError = true; - _reconnectState.Cts?.Cancel(); - IntegrationEvents.RaiseConnectionUpdate(Connected, SubathonEventSource.StreamElements, "User", "Socket"); + _reconnectState.Cts?.Cancel(); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.StreamElements, + Service = "Socket", + Name = "User", + Status = Connected + }); ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.StreamElements), "StreamElements Token could not be validated", DateTime.Now.ToLocalTime()); } diff --git a/SubathonManager.Integration/StreamLabsService.cs b/SubathonManager.Integration/StreamLabsService.cs index 3a1e551..7f7eb29 100644 --- a/SubathonManager.Integration/StreamLabsService.cs +++ b/SubathonManager.Integration/StreamLabsService.cs @@ -8,6 +8,7 @@ using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; namespace SubathonManager.Integration; @@ -52,7 +53,13 @@ public async Task InitClientAsync() { GetTokenFromConfig(); - IntegrationEvents.RaiseConnectionUpdate(false, SubathonEventSource.StreamLabs, "User", "Socket"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.StreamLabs, + Service = "Socket", + Name = "User", + Status = false + }); if (_secretToken.Equals(string.Empty)) { @@ -76,8 +83,13 @@ public async Task InitClientAsync() ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), message, DateTime.Now); } - - IntegrationEvents.RaiseConnectionUpdate(Connected, SubathonEventSource.StreamLabs, "User", "Socket"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.StreamLabs, + Service = "Socket", + Name = "User", + Status = Connected + }); _logger?.LogInformation("StreamLabs Service Connected"); return Connected; } @@ -139,8 +151,14 @@ public async Task DisconnectAsync() if (_client == null) return; _client.OnDonation -= OnDonation; - await _client.DisconnectAsync(); - IntegrationEvents.RaiseConnectionUpdate(Connected, SubathonEventSource.StreamLabs, "User", "Socket"); + await _client.DisconnectAsync(); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.StreamLabs, + Service = "Socket", + Name = "User", + Status = Connected + }); _logger?.LogInformation("StreamLabsService Disconnected"); } } \ No newline at end of file diff --git a/SubathonManager.Integration/TwitchService.cs b/SubathonManager.Integration/TwitchService.cs index f3a439b..ee710e9 100644 --- a/SubathonManager.Integration/TwitchService.cs +++ b/SubathonManager.Integration/TwitchService.cs @@ -16,10 +16,12 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Objects; using SubathonManager.Services; using TwitchLib.Client.Events; using TwitchLib.EventSub.Core.EventArgs.Stream; using TwitchLib.EventSub.Websockets.Core.EventArgs; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; @@ -108,6 +110,12 @@ public async Task ValidateTokenAsync() _logger?.LogInformation($"Twitch Token Valid for Scopes: {string.Join(',', validation.Scopes)}"); return true; } + catch (HttpRequestException ex) + { + _logger?.LogError(ex, "Could not validate token. Internet connection may be down."); + // return true so we don't delete file + return true; + } catch (Exception ex) { _logger?.LogError(ex, "Twitch Token Validation Error"); @@ -316,12 +324,24 @@ private async Task InitializeApiAsync() UserId = user.Id; _logger?.LogDebug($"Authenticated as {UserName}"); - IntegrationEvents.RaiseConnectionUpdate(true, SubathonEventSource.Twitch, UserName!, "API"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Twitch, + Service = "API", + Name = UserName!, + Status = true + }); } else { - Login = string.Empty; - IntegrationEvents.RaiseConnectionUpdate(false, SubathonEventSource.Twitch, "", "API"); + Login = string.Empty; + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Twitch, + Service = "API", + Name = "", + Status = false + }); } } @@ -336,12 +356,29 @@ private async Task InitializeChatAsync() try { _chat.Initialize(credentials, channel: UserName); - IntegrationEvents.RaiseConnectionUpdate(true, SubathonEventSource.Twitch, UserName!, "Chat"); + await Task.Run(async () => + { + await Task.Delay(1000); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Twitch, + Service = "Chat", + Name = UserName!, + Status = true + }); + }); + _logger?.LogDebug("[Twitch] Authenticated Chat as {UserName}", UserName); } catch (Exception ex) { _logger?.LogError(ex, ex.Message); - IntegrationEvents.RaiseConnectionUpdate(false, SubathonEventSource.Twitch, UserName!, "Chat"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Twitch, + Service = "Chat", + Name = UserName!, + Status = false + }); } _chat.OnMessageReceived += HandleMessageCmdReceived; @@ -356,8 +393,14 @@ private void HandleChatDisconnect(object? _, TwitchLib.Communication.Events.OnDi if ((DateTime.Now - _lastChatDisconnectLog).TotalSeconds > 60) { _logger?.LogWarning("Twitch Chat Disconnected. Attempting Reconnect..."); - _lastChatDisconnectLog = DateTime.Now; - IntegrationEvents.RaiseConnectionUpdate(false, SubathonEventSource.Twitch, UserName!, "Chat"); + _lastChatDisconnectLog = DateTime.Now; + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Twitch, + Service = "Chat", + Name = UserName!, + Status = false + }); } Task.Run(TryReconnectChatAsync); } @@ -396,7 +439,13 @@ private async Task TryReconnectChatAsync() if (_chat.IsConnected) { _logger?.LogDebug("Twitch Chat reconnect successful."); - IntegrationEvents.RaiseConnectionUpdate(true, SubathonEventSource.Twitch, UserName!, "Chat"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Twitch, + Service = "Chat", + Name = UserName!, + Status = true + }); return; } @@ -430,7 +479,13 @@ private void HandleChatReconnect(object? _, TwitchLib.Communication.Events.OnRec _logger?.LogInformation("Twitch Chat Reconnected"); _chatReconnect.Cts?.Cancel(); _chatReconnect.Reset(); - IntegrationEvents.RaiseConnectionUpdate(true, SubathonEventSource.Twitch, UserName!, "Chat"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Twitch, + Service = "Chat", + Name = UserName!, + Status = true + }); } @@ -551,7 +606,14 @@ private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) _eventSubReconnect.Cts?.Cancel(); _eventSubReconnect.Reset(); } - IntegrationEvents.RaiseConnectionUpdate(IsEventSubConnected(), SubathonEventSource.Twitch, UserName!, "EventSub"); + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Twitch, + Service = "EventSub", + Name = UserName!, + Status = IsEventSubConnected() + }); } @@ -565,6 +627,17 @@ private Task HandleEventSubReconnect(object? s, WebsocketReconnectedArgs e) _eventSubReconnect.Cts?.Cancel(); _eventSubReconnect.Reset(); _isConnected = true; + if (_chat is { IsConnected: true }) + { + // eventsub disconnect can false-disconnect chat sometimes. + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Twitch, + Service = "Chat", + Name = UserName!, + Status = true + }); + } return Task.CompletedTask; } @@ -577,7 +650,13 @@ private Task HandleEventSubDisconnect(object? s, WebsocketDisconnectedArgs e) "Twitch EventSub has disconnected", DateTime.Now.ToLocalTime()); _isConnected = false; - IntegrationEvents.RaiseConnectionUpdate(_isConnected, SubathonEventSource.Twitch, UserName!, "EventSub"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Twitch, + Service = "EventSub", + Name = UserName!, + Status = _isConnected + }); _ = Task.Run(TryReconnectEventSubAsync); return Task.CompletedTask; } @@ -760,7 +839,7 @@ private Task HandleSubGift(object? s, ChannelSubscriptionGiftArgs e) Guid.TryParse(eventMeta!.MessageId, out var mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); var user = e.Payload.Event.UserName; - if (string.IsNullOrWhiteSpace(user)) + if (e.Payload.Event.IsAnonymous || string.IsNullOrWhiteSpace(user)) user = "Anonymous"; SubathonEvent subathonEvent = new SubathonEvent { @@ -825,13 +904,15 @@ private Task HandleBitsUse(object? s, ChannelBitsUseArgs e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; Guid.TryParse(eventMeta!.MessageId, out var mId); + var user = e.Payload.Event.UserName; + if (string.IsNullOrWhiteSpace(user)) user = "Anonymous"; if (mId == Guid.Empty) mId = Guid.NewGuid(); SubathonEvent subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchCheer, - User = e.Payload.Event.UserName, + User = user, Currency = "bits", Value = e.Payload.Event.Bits.ToString(), EventTimestamp = eventMeta.MessageTimestamp.ToLocalTime() @@ -953,7 +1034,7 @@ public async Task StopAsync(CancellationToken ct = default) { // api has no disconnect? OnTeardown(); - if (_chat != null) _chat.Disconnect(); + _chat?.Disconnect(); if (_eventSub != null) await _eventSub.DisconnectAsync(); } diff --git a/SubathonManager.Integration/YouTubeService.cs b/SubathonManager.Integration/YouTubeService.cs index 8623ed3..5660481 100644 --- a/SubathonManager.Integration/YouTubeService.cs +++ b/SubathonManager.Integration/YouTubeService.cs @@ -9,6 +9,7 @@ using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; using SubathonManager.Services; // ReSharper disable NullableWarningSuppressionIsUsed @@ -79,7 +80,13 @@ public bool Start(string? handle) { Running = false; _reconnectState.Reset(); - IntegrationEvents.RaiseConnectionUpdate(Running, SubathonEventSource.YouTube, "None", "Chat"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.YouTube, + Service = "Chat", + Name = "None", + Status = Running + }); _ytHandle = handle ?? _config.Get("YouTube", "Handle")!; if (string.IsNullOrWhiteSpace(_ytHandle) || _ytHandle.Trim() == "@") @@ -91,6 +98,13 @@ public bool Start(string? handle) if (!_ytHandle.StartsWith("@")) _ytHandle = "@" + _ytHandle; _logger?.LogInformation("Youtube Service Starting for " + _ytHandle); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.YouTube, + Service = "Chat", + Name = _ytHandle!, + Status = Running + }); _ytLiveChat.Start(handle: _ytHandle, overwrite: true); return true; } @@ -117,15 +131,26 @@ private void OnInitialPageLoaded(object? sender, InitialPageLoadedEventArgs e) Running = true; _reconnectState.Reset(); _reconnectState.Cts?.Cancel(); - - IntegrationEvents.RaiseConnectionUpdate(Running, SubathonEventSource.YouTube, _ytHandle!, "Chat"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.YouTube, + Service = "Chat", + Name = _ytHandle!, + Status = Running + }); _logger?.LogInformation($"Successfully loaded YouTube Live ID: {e.LiveId}"); } private void OnChatStopped(object? sender, ChatStoppedEventArgs e) { Running = false; _logger?.LogWarning("YT Chat stopped"); - IntegrationEvents.RaiseConnectionUpdate(Running, SubathonEventSource.YouTube, _ytHandle!, "Chat"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.YouTube, + Service = "Chat", + Name = _ytHandle!, + Status = Running + }); TryReconnectLoop(); } @@ -157,7 +182,13 @@ private void OnErrorOccurred(object? sender, ErrorOccurredEventArgs e) _logger?.LogWarning(ex, "YT Error Occurred"); } - IntegrationEvents.RaiseConnectionUpdate(Running, SubathonEventSource.YouTube, _ytHandle!, "Chat"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.YouTube, + Service = "Chat", + Name = _ytHandle!, + Status = Running + }); } private void OnChatReceived(object? sender, ChatReceivedEventArgs e) @@ -167,7 +198,13 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) if (!Running) { - IntegrationEvents.RaiseConnectionUpdate(true, SubathonEventSource.YouTube, _ytHandle!, "Chat"); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.YouTube, + Service = "Chat", + Name = _ytHandle!, + Status = true + }); _reconnectState.Cts?.Cancel(); _reconnectState.Reset(); } diff --git a/SubathonManager.Server/WebServer.Api.cs b/SubathonManager.Server/WebServer.Api.cs index 9974b1b..86a700a 100644 --- a/SubathonManager.Server/WebServer.Api.cs +++ b/SubathonManager.Server/WebServer.Api.cs @@ -6,6 +6,7 @@ using SubathonManager.Core.Enums; using SubathonManager.Integration; using SubathonManager.Data; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Server; @@ -29,8 +30,8 @@ private void SetupApiRoutes() _routes.Add((new RouteKey("GET", "/api/select"),HandleSelectAsync)); _routes.Add((new RouteKey("POST", "/api/update-position/"),HandleWidgetUpdateAsync )); - _routes.Add((new RouteKey("POST", "/api/update-size/"),HandleWidgetUpdateAsync)); + _routes.Add((new RouteKey("POST", "/api/update-dimensions/"),HandleWidgetUpdateAsync)); } internal async Task HandleSelectAsync(IHttpContext ctx) @@ -76,6 +77,8 @@ internal async Task HandleWidgetUpdateAsync(IHttpContext ctx) success = await widgetHelper.UpdateWidgetScale(widgetId, data); else if (path.StartsWith("/api/update-position/", StringComparison.OrdinalIgnoreCase)) success = await widgetHelper.UpdateWidgetPosition(widgetId, data); + else if (path.StartsWith("/api/update-dimensions/", StringComparison.OrdinalIgnoreCase)) + success = await widgetHelper.UpdateWidgetDimensions(widgetId, data); if (success) { @@ -127,11 +130,11 @@ private async Task HandleDataControlRequestAsync(IHttpContext ctx) { success = ExternalEventService.ProcessExternalCommand(data); } - else if (((SubathonEventType?)type).IsCurrencyDonation() && ((SubathonEventType?)type).IsExternalType()) + else if (((SubathonEventType?)type).IsCurrencyDonation() && ((SubathonEventType?)type).IsExternal()) { success = ExternalEventService.ProcessExternalDonation(data); } - else if (((SubathonEventType?)type).IsSubOrMembershipType() && ((SubathonEventType?)type).IsExternalType()) + else if (((SubathonEventType?)type).IsSubscription() && ((SubathonEventType?)type).IsExternal()) { success = ExternalEventService.ProcessExternalSub(data); } @@ -307,7 +310,7 @@ static string NormalizeTier(string meta) } ); } - else if (g.Key.IsSubOrMembershipType()) + else if (g.Key.IsSubscription()) { result[key] = g.GroupBy(e => NormalizeTier(e.Value)) .ToDictionary( @@ -315,11 +318,11 @@ static string NormalizeTier(string meta) t => t.Sum(x => x.Amount) ); } - else if (g.Key.IsCheerType()) + else if (g.Key.IsToken()) { result[key] = g.Sum(e => int.TryParse(e.Value, out var v) ? v : 0); } - else if (g.Key.IsOrderType()) + else if (g.Key.IsOrder()) { var breakdown = g .Where(e => !string.IsNullOrWhiteSpace(e.Currency)) diff --git a/SubathonManager.Server/WebServer.Overlays.cs b/SubathonManager.Server/WebServer.Overlays.cs index bc22e65..01cba8e 100644 --- a/SubathonManager.Server/WebServer.Overlays.cs +++ b/SubathonManager.Server/WebServer.Overlays.cs @@ -127,7 +127,7 @@ internal async Task HandleRouteRequest(IHttpContext ctx) } } } - await ctx.WriteResponse(404, "Route not found"); + await ctx.WriteResponse(404, "Route/Overlay not found"); } private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false) @@ -216,6 +216,11 @@ @keyframes pulse {{ border-color: darkorange !important; }} + .resize-handle.ctrl-dimension {{ + background: #50e890 !important; + border-color: #20b860 !important; + }} + .handle-nw {{ top: -6px; left: -6px; cursor: nwse-resize; }} .handle-ne {{ top: -6px; right: -6px; cursor: nesw-resize; }} .handle-sw {{ bottom: -6px; left: -6px; cursor: nesw-resize; }} @@ -401,27 +406,39 @@ await fetch(`/api/select/${id}`, { let baselineWidth, baselineHeight; let startLeft, startTop; let isShiftHeld = false; + let isCtrlHeld = false; - wrapper.querySelectorAll('.resize-handle').forEach(handle => {{ + const EDGE_HANDLES = ['handle-n', 'handle-s', 'handle-e', 'handle-w']; + const CORNER_HANDLES = ['handle-nw', 'handle-ne', 'handle-sw', 'handle-se']; - // aspect ratio mode on - document.addEventListener('keydown', (e) => {{ - if (e.key === 'Shift') {{ - isShiftHeld = true; - document.querySelectorAll('.handle-nw, .handle-ne, .handle-sw, .handle-se') - .forEach(handle => handle.classList.add('shift-active')); - }} - }}); + function isEdgeHandle(handle) {{ + return EDGE_HANDLES.some(c => handle.classList.contains(c)); + }} + function isCornerHandle(handle) {{ + return CORNER_HANDLES.some(c => handle.classList.contains(c)); + }} - // aspect ratio mode off - document.addEventListener('keyup', (e) => {{ - if (e.key === 'Shift') {{ - isShiftHeld = false; - document.querySelectorAll('.handle-nw, .handle-ne, .handle-sw, .handle-se') - .forEach(handle => handle.classList.remove('shift-active')); + function updateHandleIndicators() {{ + wrapper.querySelectorAll('.resize-handle').forEach(h => {{ + h.classList.remove('shift-active', 'ctrl-dimension'); + if (isCtrlHeld && !isShiftHeld) {{ + h.classList.add('ctrl-dimension'); + }} else if (isShiftHeld && !isCtrlHeld) {{ + if (isCornerHandle(h)) h.classList.add('shift-active'); }} }}); + }} + + document.addEventListener('keydown', (e) => {{ + if (e.key === 'Shift') {{ isShiftHeld = true; updateHandleIndicators(); }} + if (e.key === 'Control') {{ isCtrlHeld = true; updateHandleIndicators(); }} + }}); + document.addEventListener('keyup', (e) => {{ + if (e.key === 'Shift') {{ isShiftHeld = false; updateHandleIndicators(); }} + if (e.key === 'Control') {{ isCtrlHeld = false; updateHandleIndicators(); }} + }}); + wrapper.querySelectorAll('.resize-handle').forEach(handle => {{ handle.addEventListener('mousedown', e => {{ e.stopPropagation(); isResizing = true; @@ -438,8 +455,52 @@ await fetch(`/api/select/${id}`, { document.addEventListener('mousemove', e => {{ if (!isResizing) return; - let dx = (e.clientX - startX) / scaleX; - let dy = (e.clientY - startY) / scaleY; + const dx = (e.clientX - startX) / scaleX; + const dy = (e.clientY - startY) / scaleY; + + if (e.ctrlKey && !e.shiftKey) {{ + let newWidth = baselineWidth; + let newHeight = baselineHeight; + let newLeft = startLeft; + let newTop = startTop; + + if (activeHandle.classList.contains('handle-e') || + activeHandle.classList.contains('handle-ne') || + activeHandle.classList.contains('handle-se')) {{ + newWidth = Math.max(MIN_WIDTH, baselineWidth + dx); + }} + + if (activeHandle.classList.contains('handle-w') || + activeHandle.classList.contains('handle-nw') || + activeHandle.classList.contains('handle-sw')) {{ + const clamped = Math.max(MIN_WIDTH, baselineWidth - dx); + newLeft = startLeft + (baselineWidth - clamped) * scaleX; + newWidth = clamped; + }} + + if (activeHandle.classList.contains('handle-s') || + activeHandle.classList.contains('handle-se') || + activeHandle.classList.contains('handle-sw')) {{ + newHeight = Math.max(MIN_HEIGHT, baselineHeight + dy); + }} + + if (activeHandle.classList.contains('handle-n') || + activeHandle.classList.contains('handle-nw') || + activeHandle.classList.contains('handle-ne')) {{ + const clamped = Math.max(MIN_HEIGHT, baselineHeight - dy); + newTop = startTop + (baselineHeight - clamped) * scaleY; + newHeight = clamped; + }} + + iframe.style.width = newWidth + 'px'; + iframe.style.height = newHeight + 'px'; + iframe.style.transform = `scale(${{scaleX}}, ${{scaleY}})`; + wrapper.style.width = (newWidth * scaleX) + 'px'; + wrapper.style.height = (newHeight * scaleY) + 'px'; + wrapper.style.left = newLeft + 'px'; + wrapper.style.top = newTop + 'px'; + return; + }} let newWidth = baselineWidth; let newHeight = baselineHeight; @@ -469,14 +530,8 @@ await fetch(`/api/select/${id}`, { newTop = startTop + dy * scaleY; }} - if (e.shiftKey && ( - activeHandle.classList.contains('handle-nw') || - activeHandle.classList.contains('handle-ne') || - activeHandle.classList.contains('handle-sw') || - activeHandle.classList.contains('handle-se') - )) {{ + if (e.shiftKey && !e.ctrlKey && isCornerHandle(activeHandle)) {{ const aspectRatio = baselineWidth / baselineHeight; - let candidateWidth = newWidth; let candidateHeight = newHeight; @@ -486,14 +541,8 @@ await fetch(`/api/select/${id}`, { candidateWidth = candidateHeight * aspectRatio; }} - if (candidateWidth < MIN_WIDTH) {{ - candidateWidth = MIN_WIDTH; - candidateHeight = candidateWidth / aspectRatio; - }} - if (candidateHeight < MIN_HEIGHT) {{ - candidateHeight = MIN_HEIGHT; - candidateWidth = candidateHeight * aspectRatio; - }} + if (candidateWidth < MIN_WIDTH) {{ candidateWidth = MIN_WIDTH; candidateHeight = candidateWidth / aspectRatio;}} + if (candidateHeight < MIN_HEIGHT) {{ candidateHeight = MIN_HEIGHT; candidateWidth = candidateHeight * aspectRatio;}} newWidth = candidateWidth; newHeight = candidateHeight; @@ -516,7 +565,6 @@ await fetch(`/api/select/${id}`, { }} newWidth = MIN_WIDTH; }} - if (newHeight < MIN_HEIGHT) {{ if (activeHandle.classList.contains('handle-n') || activeHandle.classList.contains('handle-nw') || @@ -542,13 +590,31 @@ await fetch(`/api/select/${id}`, { if (!isResizing) return; isResizing = false; + const id = wrapper.dataset.id; + + if (e.ctrlKey && !e.shiftKey) {{ + const newWidth = Math.round(parseFloat(iframe.style.width)); + const newHeight = Math.round(parseFloat(iframe.style.height)); + const x = wrapper.offsetLeft; + const y = wrapper.offsetTop; + + wrapper.dataset.origWidth = newWidth; + wrapper.dataset.origHeight = newHeight; + + fetch(`/api/update-dimensions/${{id}}`, {{ + method: 'POST', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify({{ width: newWidth, height: newHeight, x, y }}) + }}); + return; + }} + scaleX = parseFloat(iframe.style.transform.match(/scale\(([^,]+)/)[1]); scaleY = parseFloat(iframe.style.transform.match(/scale\([^,]+,\s*([^)]+)/)[1]); iframe.dataset.scalex = scaleX; iframe.dataset.scaley = scaleY; - const id = wrapper.dataset.id; fetch(`/api/update-size/${{id}}`, {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }}, diff --git a/SubathonManager.Server/WebServer.WebSocket.cs b/SubathonManager.Server/WebServer.WebSocket.cs index 772333f..6f5fc23 100644 --- a/SubathonManager.Server/WebServer.WebSocket.cs +++ b/SubathonManager.Server/WebServer.WebSocket.cs @@ -6,7 +6,10 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Models; using SubathonManager.Core.Events; +using SubathonManager.Core.Objects; using SubathonManager.Integration; +using SubathonManager.Services; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Server; @@ -24,6 +27,7 @@ private void SetupWebsocketListeners() SubathonEvents.SubathonGoalListUpdated += SendGoalsUpdated; OverlayEvents.OverlayRefreshRequested += SendRefreshRequest; SubathonEvents.SubathonValueConfigRequested += SendSubathonValues; + SubathonEvents.SubathonTotalsUpdated += SendSubathonTotals; } private void StopWebsocketServer() @@ -34,6 +38,7 @@ private void StopWebsocketServer() OverlayEvents.OverlayRefreshRequested -= SendRefreshRequest; SubathonEvents.SubathonGoalListUpdated -= SendGoalsUpdated; SubathonEvents.SubathonValueConfigRequested -= SendSubathonValues; + SubathonEvents.SubathonTotalsUpdated -= SendSubathonTotals; } internal void SendSubathonValues(string jsonData) @@ -100,12 +105,55 @@ private async Task InitConnection(IWebSocketClient socket) { type = "goals_list", points = val, - goals = goalSet!.Goals.Select(goal => GoalToObject(goal, val)).ToArray(), + goals = goalSet.Goals.Select(goal => GoalToObject(goal, val)).ToArray(), goals_type = $"{goalSet.Type}" }; await SelectSendAsync(socket, data); } + var totals = await EventService.GetSubathonTotalsAsync(db); + + if (totals != null) + await SelectSendAsync(socket, SubathonTotalsToObject(totals)); + + } + + private object SubathonTotalsToObject(SubathonTotals totals) + { + return new + { + type = "subathon_totals", + currency = totals.Currency, + money_sum = totals.MoneySum, + sub_like_total = totals.SubLikeTotal, + sub_like_by_type = totals.SubLikeByEvent + .ToDictionary(k => k.Key.ToString(), k => k.Value), + token_like_total = totals.TokenLikeTotal, + token_like_by_type = totals.TokenLikeByEvent + .ToDictionary(k => k.Key.ToString(), k => k.Value), + order_count_by_type = totals.OrderCountByType + .ToDictionary(k => k.Key.ToString(), k => k.Value), + order_items_count_by_type = totals.OrderItemsCountByType + .ToDictionary(k => k.Key.ToString(), k => k.Value), + follow_count = totals.FollowLikeTotal, + follow_count_by_type = totals.FollowLikeByEvent + .ToDictionary(k => k.Key.ToString(), k => k.Value), + simulated = new { + sub_like_total = totals.Simulated.SubLikeTotal, + sub_like_by_type = totals.Simulated.SubLikeByEvent + .ToDictionary(k => k.Key.ToString(), k => k.Value), + token_like_total = totals.Simulated.TokenLikeTotal, + token_like_by_type = totals.Simulated.TokenLikeByEvent + .ToDictionary(k => k.Key.ToString(), k => k.Value), + order_count_by_type = totals.Simulated.OrderCountByType + .ToDictionary(k => k.Key.ToString(), k => k.Value), + order_items_count_by_type = totals.Simulated.OrderItemsCountByType + .ToDictionary(k => k.Key.ToString(), k => k.Value), + follow_count = totals.Simulated.FollowLikeTotal, + follow_count_by_type = totals.Simulated.FollowLikeByEvent + .ToDictionary(k => k.Key.ToString(), k => k.Value), + } + }; } private object SubathonEventToObject(SubathonEvent subathonEvent) @@ -125,7 +173,8 @@ private object SubathonEventToObject(SubathonEvent subathonEvent) event_timestamp = subathonEvent.EventTimestamp, reversed = subathonEvent.WasReversed, sub_type = subathonEvent.EventType.GetSubType().ToString(), - secondary_value = subathonEvent.SecondaryValue + secondary_value = subathonEvent.SecondaryValue, + type_true_source = subathonEvent.EventType.GetTypeTrueSource() }; return data; } @@ -136,6 +185,11 @@ internal void SendSubathonEventProcessed(SubathonEvent subathonEvent, bool effec Task.Run(() => BroadcastAsyncObject(SubathonEventToObject(subathonEvent), WebsocketClientTypeHelper.ConsumersList)); } + internal void SendSubathonTotals(SubathonTotals totals) + { + Task.Run(() => BroadcastAsyncObject(SubathonTotalsToObject(totals), WebsocketClientTypeHelper.ConsumersList)); + } + internal void SendRefreshRequest(Guid id) { Task.Run(() => @@ -154,7 +208,7 @@ private object SubathonDataToObject(SubathonData subathon) && subathon.Multiplier.Started != null) { DateTime? multEndTime = subathon.Multiplier.Started + subathon.Multiplier.Duration; - multiplierRemaining = multEndTime! - DateTime.Now; + multiplierRemaining = multEndTime - DateTime.Now; } long roundedMoney = subathon.GetRoundedMoneySum(); @@ -346,13 +400,13 @@ private async Task Listen(IWebSocketClient socket) .EnumerateObject() .ToDictionary(p => p.Name, p => p.Value); - if (((SubathonEventType?)seType).IsCurrencyDonation() && ((SubathonEventType?)seType).IsExternalType()) + if (((SubathonEventType?)seType).IsCurrencyDonation() && ((SubathonEventType?)seType).IsExternal()) { if (!socket.IntegrationSources.Contains(((SubathonEventType?)seType).GetSource())) socket.IntegrationSources.Add(((SubathonEventType?)seType).GetSource()); ExternalEventService.ProcessExternalDonation(data); } - else if (((SubathonEventType?)seType).IsSubOrMembershipType() && ((SubathonEventType?)seType).IsExternalType()) + else if (((SubathonEventType?)seType).IsSubscription() && ((SubathonEventType?)seType).IsExternal()) { if (!socket.IntegrationSources.Contains(((SubathonEventType?)seType).GetSource())) socket.IntegrationSources.Add(((SubathonEventType?)seType).GetSource()); @@ -491,6 +545,8 @@ function connect() {{ window.handleGoalCompleted(data); else if (typeof window.handleValueConfig === 'function' && data.type == 'value_config') window.handleValueConfig(data); + else if (typeof window.handleTotalsUpdate === 'function' && data.type == 'subathon_totals') + window.handleTotalsUpdate(data); else if (data.type == 'refresh_request' && document.title.startsWith('overlay') && (document.title.includes(data.id) || data.id == '{Guid.Empty}')) {{ // for only the merged page window.location.reload(); diff --git a/SubathonManager.Services/DiscordWebhookService.cs b/SubathonManager.Services/DiscordWebhookService.cs index 99ac587..96da337 100644 --- a/SubathonManager.Services/DiscordWebhookService.cs +++ b/SubathonManager.Services/DiscordWebhookService.cs @@ -40,7 +40,6 @@ public class DiscordWebhookService : IDisposable, IAppService private const string AppAvatarUrl = "https://raw.githubusercontent.com/WolfwithSword/SubathonManager/refs/heads/main/assets/icon.png"; - // todo handle rate limit and retry_after public DiscordWebhookService(ILogger? logger, IConfig config, CurrencyService currencyService) { _logger = logger; @@ -324,7 +323,7 @@ private string BuildEventDescription(SubathonEvent e) return sb.ToString(); } - private async Task SendWebhookAsync(object payload, string? url) + private async Task SendWebhookAsync(object payload, string? url, int retryCount = 0) { if (string.IsNullOrEmpty(url)) return; try @@ -334,12 +333,34 @@ private async Task SendWebhookAsync(object payload, string? url) var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await http.PostAsync(url, content, _cts.Token); + string rc = ""; + + if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests && retryCount < 3) + { + rc = await response.Content.ReadAsStringAsync(); + double retryAfter = 1.0; + try + { + using var doc = JsonDocument.Parse(rc); + if (doc.RootElement.TryGetProperty("retry_after", out var ra)) + retryAfter = ra.GetDouble(); + } + catch {/**/} + + int delayMs = (int)(retryAfter * 1000) + 200; + _logger?.LogWarning("Discord rate limited, retrying in {DelayMs}ms, attempt #{Attempt}", delayMs, retryCount); + await Task.Delay(delayMs, _cts.Token); + await SendWebhookAsync(payload, url, retryCount + 1); + return; + } + if (!response.IsSuccessStatusCode) { - var rc = await response.Content.ReadAsStringAsync(); + rc = await response.Content.ReadAsStringAsync(); _logger?.LogError("Webhook failed: {ResponseStatusCode} - {Rc}", response.StatusCode, rc); } } + catch (OperationCanceledException) { /**/ } catch (Exception ex) { _logger?.LogError(ex, ex.Message); diff --git a/SubathonManager.Services/EventService.cs b/SubathonManager.Services/EventService.cs index 1b5c1bd..b6870f7 100644 --- a/SubathonManager.Services/EventService.cs +++ b/SubathonManager.Services/EventService.cs @@ -7,6 +7,8 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Objects; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Services; @@ -24,6 +26,7 @@ public class EventService: IDisposable, IAppService private readonly CurrencyService _currencyService; private readonly ILogger? _logger; private readonly IConfig _config; + private record EventProjection(SubathonEventType? EventType, SubathonEventSource Source, int Amount, string Value, bool IsSimulated); public EventService(IDbContextFactory factory, ILogger? logger, IConfig config, CurrencyService currencyService) @@ -157,7 +160,9 @@ private async Task LoopAsync() ev.WasReversed = subathon.IsSubathonReversed(); SubathonValue? subathonValue = null; - if (ev.EventType != SubathonEventType.Command && ev.EventType != SubathonEventType.ExternalSub && + if (ev.EventType != SubathonEventType.Command && + (ev.EventType != SubathonEventType.ExternalSub || + ev is { EventType: SubathonEventType.ExternalSub, SecondsValue: 0, PointsValue: 0 }) && ev.EventType != SubathonEventType.DonationAdjustment) { subathonValue = await db.SubathonValues.FirstOrDefaultAsync(v => @@ -173,7 +178,9 @@ private async Task LoopAsync() return (false, false); } - if (ev.EventType != SubathonEventType.Command && ev.EventType != SubathonEventType.ExternalSub && + if (ev.EventType != SubathonEventType.Command && + (ev.EventType != SubathonEventType.ExternalSub || + ev is { EventType: SubathonEventType.ExternalSub, SecondsValue: 0, PointsValue: 0 }) && ev.EventType != SubathonEventType.DonationAdjustment) { /////////////////////////////////////////////////////////////// @@ -191,8 +198,7 @@ private async Task LoopAsync() } else if (!string.IsNullOrEmpty(ev.Currency) && _currencyService.IsValidCurrency(ev.Currency) && (ev.EventType.IsCurrencyDonation() || - ev.EventType - .IsOrderType())) // includes orders when parsed as money mode + ev.EventType.IsOrder())) // includes orders when parsed as money mode { double rate = Task.Run(() => _currencyService.ConvertAsync(double.Parse(ev.Value), ev.Currency)).Result; @@ -212,7 +218,7 @@ private async Task LoopAsync() ev.Currency = "???"; } - if (ev.EventType.IsCheerType() && double.TryParse(ev.Value, out var parsedBitsLikeValue)) + if (ev.EventType.IsToken() && double.TryParse(ev.Value, out var parsedBitsLikeValue)) { ev.PointsValue = (int) Math.Floor(((parsedBitsLikeValue / 100)) * subathonValue!.Points); } @@ -266,16 +272,16 @@ private async Task LoopAsync() } if (affected > 0 || ev.EventType == SubathonEventType.DonationAdjustment || - (ev.EventType.IsOrderType() && _config.GetBool(ev.EventType.GetSource().ToString(), + (ev.EventType.IsOrder() && _config.GetBool(ev.EventType.GetSource().ToString(), $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", false) && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|'))) { ev.ProcessedToSubathon = true; (bool asDono, double modifier) = Utils.GetAltCurrencyUseAsDonation(_config, ev.EventType); - if (ev.EventType.IsOrderType() && _config.GetBool(ev.EventType.GetSource().ToString(), - $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", false) - && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|')) + if (ev.EventType.IsOrder() && _config.GetBool(ev.EventType.GetSource().ToString(), + $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", false) + && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|')) { var value = ev.SecondaryValue.Split('|')[0]; var currency = ev.SecondaryValue.Split('|')[1]; @@ -339,6 +345,14 @@ await db.Database.ExecuteSqlRawAsync( SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); } db.Entry(subathon).State = EntityState.Detached; + + if (affected > 0 || (ev.ProcessedToSubathon)) + { + var totals = await GetSubathonTotalsAsync(db); + if (totals != null) + SubathonEvents.RaiseSubathonTotalsUpdated(totals); + } + return (affected > 0 || (ev.ProcessedToSubathon), false); } @@ -512,6 +526,13 @@ await db.Database.ExecuteSqlRawAsync( long pts = subathon.Points; if (goalSet?.Type == GoalsType.Money) pts = subathon.GetRoundedMoneySum(); await CheckForGoalChange(db, pts, initialPoints); + + if (ev.Command is SubathonCommandType.AddMoney or SubathonCommandType.SubtractMoney or SubathonCommandType.AddPoints or SubathonCommandType.SubtractPoints or SubathonCommandType.SetPoints) + { + var totals = await GetSubathonTotalsAsync(db); + if (totals != null) + SubathonEvents.RaiseSubathonTotalsUpdated(totals); + } return (true, false, true); } @@ -583,7 +604,7 @@ public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) } } - if (ev.EventType.IsOrderType() && ev.ProcessedToSubathon && _config.GetBool(ev.EventType.GetSource().ToString(), + if (ev.EventType.IsOrder() && ev.ProcessedToSubathon && _config.GetBool(ev.EventType.GetSource().ToString(), $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", false) && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|')) { @@ -647,6 +668,10 @@ public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) events.Add(ev); SubathonEvents.RaiseSubathonEventsDeleted(events); + var totals = await GetSubathonTotalsAsync(db); + if (totals != null) + SubathonEvents.RaiseSubathonTotalsUpdated(totals); + if (affected > 0) { await db.Entry(subathon!).ReloadAsync(); @@ -699,11 +724,11 @@ public async Task UndoSimulatedEvents(AppDbContext db, List event moneyToRemove += await _currencyService.ConvertAsync(double.Parse(ev.Value), ev.Currency!, subathon.Currency!); } - else if (ev.EventType.IsOrderType() && _config.GetBool(ev.EventType.GetSource().ToString(), - $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", - false) - && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && - ev.SecondaryValue.Contains('|')) + else if (ev.EventType.IsOrder() && _config.GetBool(ev.EventType.GetSource().ToString(), + $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", + false) + && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && + ev.SecondaryValue.Contains('|')) { var value = ev.SecondaryValue.Split('|')[0]; var currency = ev.SecondaryValue.Split('|')[1]; @@ -760,7 +785,7 @@ public async Task UndoSimulatedEvents(AppDbContext db, List event .ToListAsync(); db.RemoveRange(trackedEvents); - + await db.SaveChangesAsync(); SubathonEvents.RaiseSubathonEventsDeleted(events); @@ -775,6 +800,12 @@ public async Task UndoSimulatedEvents(AppDbContext db, List event { _logger?.LogError("UndoSimulatedEvents failed: {Message}", ex.Message); } + finally + { + var totals = await GetSubathonTotalsAsync(db); + if (totals != null) + SubathonEvents.RaiseSubathonTotalsUpdated(totals); + } } private async Task DetectGoalStateChange(AppDbContext db, SubathonData subathon, SubathonGoalSet? goalSet, @@ -811,6 +842,123 @@ private async Task DetectGoalStateChange(AppDbContext db, SubathonData subathon, } } } + + public static async Task GetSubathonTotalsAsync(AppDbContext db) + { + var subathon = await db.SubathonDatas + .AsNoTracking() + .Select(x => new { x.Id, x.IsActive, x.MoneySum, x.Currency }) + .FirstOrDefaultAsync(x => x.IsActive); + + if (subathon == null) return new SubathonTotals(); + + var allEvents = await db.SubathonEvents + .AsNoTracking() + .Where(e => + e.SubathonId == subathon.Id && + e.ProcessedToSubathon && + e.EventType != SubathonEventType.Command) + .Select(e => new EventProjection( + e.EventType, + e.Source, + e.Amount, + e.Value, + e.Source == SubathonEventSource.Simulated || + e.User!.StartsWith("SIMULATED") || + e.User!.StartsWith("SYSTEM") + )) + .ToListAsync(); + + + var events= allEvents.Where(e => !e.IsSimulated).ToList(); + var simEvents= allEvents.Where(e => e.IsSimulated).ToList(); + static SubathonSimulatedTotals BuildSimulated(List src) + { + var subLikeSim= src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.SubLike).ToList(); + var giftSubLikeSim= src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.GiftSubLike).ToList(); + var tokenLikeSim = src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.TokenLike).ToList(); + var orderLikeSim = src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.OrderLike).ToList(); + var followLikeSim= src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.FollowLike).ToList(); + + return new SubathonSimulatedTotals + { + SubLikeTotal = subLikeSim.Count + giftSubLikeSim.Sum(e => e.Amount), + SubLikeByEvent = subLikeSim + .GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Count()) + .Concat(giftSubLikeSim + .GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount))) + .ToDictionary(k => k.Key, v => v.Value),TokenLikeTotal = tokenLikeSim.Sum(e => long.TryParse(e.Value, out var v) ? v : 0), + TokenLikeByEvent = tokenLikeSim.GroupBy(e => e.EventType!.Value).ToDictionary(g => g.Key, g => g.Sum(e => long.TryParse(e.Value, out var v) ? v : 0)), + OrderCountByType = orderLikeSim.GroupBy(e => e.EventType!.Value).ToDictionary(g => g.Key, g => g.Count()), + OrderItemsCountByType = orderLikeSim + .GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount)), + FollowLikeTotal = followLikeSim.Count, + FollowLikeByEvent= followLikeSim.GroupBy(e => e.EventType!.Value).ToDictionary(g => g.Key, g => g.Count()), + }; + } + + var subLike = events + .Where(e => e.EventType.GetSubType() is SubathonEventSubType.SubLike) + .ToList(); + var giftSubLike = events + .Where(e => e.EventType.GetSubType() is SubathonEventSubType.GiftSubLike) + .ToList(); + + var tokenLike = events + .Where(e => e.EventType.GetSubType() is SubathonEventSubType.TokenLike) + .ToList(); + + var orderLike = events + .Where(e => e.EventType.GetSubType() is SubathonEventSubType.OrderLike) + .ToList(); + + var followLike = events + .Where(e => e.EventType.GetSubType() is SubathonEventSubType.FollowLike) + .ToList(); + + var simData = BuildSimulated(simEvents); + var totals = new SubathonTotals + { + MoneySum = subathon.MoneySum ?? 0, + Currency = subathon.Currency, + + SubLikeTotal = subLike.Count + giftSubLike.Sum(e => e.Amount), + SubLikeByEvent = subLike + .GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Count()) + .Concat(giftSubLike + .GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount))) + .ToDictionary(k => k.Key, v => v.Value), + + TokenLikeTotal = tokenLike + .Sum(e => long.TryParse(e.Value, out var v) ? v : 0), + TokenLikeByEvent = tokenLike + .GroupBy(e => e.EventType!.Value) + .ToDictionary( + g => g.Key, + g => g.Sum(e => long.TryParse(e.Value, out var v) ? v : 0)), + + OrderCountByType = orderLike + .GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Count()), + + OrderItemsCountByType = orderLike + .GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount)), + + FollowLikeTotal = followLike.Count, + FollowLikeByEvent = followLike + .GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Count()), + + Simulated = simData + }; + return totals; + } public async Task StopAsync(CancellationToken ct = default) { diff --git a/SubathonManager.Tests/DataUnitTests/OverlayPorterTests.cs b/SubathonManager.Tests/DataUnitTests/OverlayPorterTests.cs index 4a2e996..a8d0ad8 100644 --- a/SubathonManager.Tests/DataUnitTests/OverlayPorterTests.cs +++ b/SubathonManager.Tests/DataUnitTests/OverlayPorterTests.cs @@ -113,6 +113,28 @@ public void GetZipWidgetRoots_SinglePath_AlwaysHasHash() [Fact] public void GetZipWidgetRoots_SameParent_SharesHash() + { + var paths = new List + { + @"C:\stream\widgets\timer", + @"C:\stream\widgets\timer\alerts" + }; + var result = OverlayPorter.GetZipWidgetRoots(paths); + + Assert.Equal(2, result.Count); + + var timerParts = result[0].Split('/'); + var alertsParts = result[1].Split('/'); + + Assert.Equal("widgets", timerParts[0]); + Assert.Equal("widgets", alertsParts[0]); + Assert.Equal(timerParts[1], alertsParts[1]); + Assert.Equal("timer", timerParts[^1]); + Assert.Equal("alerts", alertsParts[^1]); + } + + [Fact] + public void GetZipWidgetRoots_SameParent_ButBothRoots_SameHash() { var paths = new List { @@ -153,7 +175,7 @@ public void GetZipWidgetRoots_DifferentDrives_GetDifferentHashs() } [Fact] - public void GetZipWidgetRoots_SharedPrefixWithDifferentLeafs_SameParentDifferentLeafs() + public void GetZipWidgetRoots_SharedPrefixWithDifferentLeafs_SameParentDifferentLeafsAsRoots_SameHash() { var paths = new List { @@ -189,7 +211,59 @@ public void GetZipWidgetRoots_ThreePaths_TwoSameDriveOneDifferent() Assert.NotEqual(cBucket, g1Bucket); Assert.Equal(g1Bucket, g2Bucket); } + + [Fact] + public void GetZipWidgetRoots_TwoPaths_TwoDrivesOtherwiseSame() + { + var paths = new List + { + @"G:\My\Path\steampunk", + @"C:\My\Path\steampunk" + }; + var result = OverlayPorter.GetZipWidgetRoots(paths); + Assert.Equal(2, result.Count); + var g1Bucket = result[0].Split('/')[1]; + var g2Bucket = result[1].Split('/')[1]; + + Assert.NotEqual(g1Bucket, g2Bucket); + } + + + [Fact] + public void GetZipWidgetRoots_TwoPathsAndDrives_AtRoots() + { + var paths = new List + { + @"G:\steampunk", + @"C:\steampunk" + }; + var result = OverlayPorter.GetZipWidgetRoots(paths); + + Assert.Equal(2, result.Count); + var g1Bucket = result[0].Split('/')[1]; + var g2Bucket = result[1].Split('/')[1]; + + Assert.NotEqual(g1Bucket, g2Bucket); + } + + + [Fact] + public void GetZipWidgetRoots_TwoPaths_AtDriveRoots_AwkwardButDiff() + { + var paths = new List + { + @"G:\", + @"C:\" + }; + var result = OverlayPorter.GetZipWidgetRoots(paths); + + Assert.Equal(2, result.Count); + var g1Bucket = result[0].Split('/')[1]; + var g2Bucket = result[1].Split('/')[1]; + Assert.NotEqual(g1Bucket, g2Bucket); + } + [Fact] public void GetZipWidgetRoots_AlwaysStartsWithWidgets() { diff --git a/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs index 0ba6750..d4a9817 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs @@ -221,6 +221,37 @@ public void ProcessExternalSub_ShouldRaiseEvent_WithDefaults() } + [Fact] + public void ProcessExternalSub_ShouldRaiseEvent_ReliesOnValueMeta() + { + var json = """ + { + "type": "ExternalSub", + "user": "", + "value": "subt1", + "amount": 3, + "id": "b3e1f7e2-1234-4a5b-9e8f-123456789abc" + } + """; + + var data = JsonSerializer.Deserialize>(json)!; + + bool result = false; + SubathonEvent? ev = CaptureEvent( () => result = ExternalEventService.ProcessExternalSub(data)); + + Assert.True(result); + Assert.NotNull(ev); + Assert.Equal("EXTERNAL", ev!.User); + Assert.Equal("subt1", ev.Value); + Assert.Equal(3, ev.Amount); + Assert.Equal(0, ev.SecondsValue); + Assert.Equal(0, ev.PointsValue); + Assert.Equal(SubathonEventSource.External, ev.Source); + Assert.Equal(SubathonEventType.ExternalSub, ev.EventType); + Assert.Equal(Guid.Parse("b3e1f7e2-1234-4a5b-9e8f-123456789abc"), ev.Id); + } + + [Fact] public void ProcessKoFiSub_ShouldRaiseEvent_WithDefaults() { @@ -373,7 +404,7 @@ public void ProcessExternalCommand_MissingMessage_DefaultsToEmpty() } [Fact] - public void ProcessExternalSub_ShouldReturnFalse_WhenSecondsOrPointsMissing() + public void ProcessExternalSub_ShouldReturnTrue_WhenSecondsOrPointsMissing() { var json = """ { @@ -387,11 +418,11 @@ public void ProcessExternalSub_ShouldReturnFalse_WhenSecondsOrPointsMissing() var data = JsonSerializer.Deserialize>(json)!; bool result = ExternalEventService.ProcessExternalSub(data); - Assert.False(result); + Assert.True(result); } [Fact] - public void ProcessExternalSub_ShouldReturnFalse_WhenPointsMissingButSecondsPresent() + public void ProcessExternalSub_ShouldReturnTrue_WhenPointsMissingButSecondsPresent() { var json = """ { @@ -406,7 +437,7 @@ public void ProcessExternalSub_ShouldReturnFalse_WhenPointsMissingButSecondsPres var data = JsonSerializer.Deserialize>(json)!; bool result = ExternalEventService.ProcessExternalSub(data); - Assert.False(result); + Assert.True(result); } [Fact] @@ -450,7 +481,7 @@ public void ProcessExternalSub_MissingValue_DefaultsToExternal() SubathonEvent? ev = CaptureEvent( () => result =ExternalEventService.ProcessExternalSub(data)); Assert.True(result); - Assert.Equal("External", ev!.Value); + Assert.Equal("DEFAULT", ev!.Value); } [Fact] diff --git a/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs index 69cb57c..0798ffd 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs @@ -4,6 +4,7 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; using SubathonManager.Integration; using SubathonManager.Tests.Utility; @@ -17,18 +18,18 @@ public class GoAffProServiceTests private static async Task<(bool?, SubathonEventSource, string, string)> CaptureIntegrationEvent(Func trigger) { - + bool? status = null; SubathonEventSource source = SubathonEventSource.Unknown; string name = string.Empty; string service = string.Empty; - void EventCaptureHandler(bool b, SubathonEventSource s, string n, string se) + void EventCaptureHandler(IntegrationConnection conn) { - status = b; - source = s; - name = n; - service = se; + status = conn.Status; + source = conn.Source; + name = conn.Name; + service = conn.Service; } IntegrationEvents.ConnectionUpdated += EventCaptureHandler; diff --git a/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs index e18b65a..d74a87f 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs @@ -13,6 +13,7 @@ using PicartoEventsLib.Clients; using PicartoEventsLib.Options; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Objects; using SubathonManager.Tests.Utility; // ReSharper disable NullableWarningSuppressionIsUsed @@ -295,21 +296,21 @@ public async Task StartAsync_Test_RaisesConnections() bool eventAlertsConnectRaised = false; bool eventChatDisconnectRaised = false; bool eventAlertsDisconnectRaised = false; - Action handler = (running, source, handle, serviceType) => + Action handler = (conn) => { - if (source == SubathonEventSource.Picarto) + if (conn.Source == SubathonEventSource.Picarto) { - if (serviceType == "Chat") - if (running) + if (conn.Service == "Chat") + if (conn.Status) eventChatConnectRaised = true; else eventChatDisconnectRaised = true; - else if (serviceType == "Alerts") - if (running) + else if (conn.Service == "Alerts") + if (conn.Status) eventAlertsConnectRaised = true; else eventAlertsDisconnectRaised = true; - Assert.Equal("TestChannel", handle); + Assert.Equal("TestChannel", conn.Name); } }; IntegrationEvents.ConnectionUpdated += handler; @@ -363,17 +364,18 @@ public async Task Test_ChangeChannel_And_Connect_RaisesConnections() bool eventAlertsConnectRaised = false; bool eventChatDisconnectRaised = false; bool eventAlertsDisconnectRaised = false; - Action handler = (running, source, handle, serviceType) => + + Action handler = (conn) => { - if (source == SubathonEventSource.Picarto) + if (conn.Source == SubathonEventSource.Picarto) { - if (serviceType == "Chat") - if (running) + if (conn.Service == "Chat") + if (conn.Status) eventChatConnectRaised = true; else eventChatDisconnectRaised = true; - else if (serviceType == "Alerts") - if (running) + else if (conn.Service == "Alerts") + if (conn.Status) eventAlertsConnectRaised = true; else eventAlertsDisconnectRaised = true; diff --git a/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs index 7edf486..b9d86e0 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs @@ -7,6 +7,7 @@ using StreamElements.WebSocket.Models.Tip; using StreamElements.WebSocket.Models.Internal; using Microsoft.Extensions.Logging; +using SubathonManager.Core.Objects; using SubathonManager.Tests.Utility; namespace SubathonManager.Tests.IntegrationUnitTests; @@ -166,12 +167,12 @@ public async Task OnDisconnected_ShouldSetConnectedFalse_AndRaiseEvent() bool eventRaised = false; - Action handler = (running, source, handle, serviceType) => + Action handler = (conn) => { - if (source == SubathonEventSource.StreamElements) + if (conn.Source == SubathonEventSource.StreamElements) { eventRaised = true; - Assert.False(running); + Assert.False(conn.Status); } }; @@ -208,13 +209,13 @@ public void OnAuthenticated_ShouldSetConnectedTrue_AndRaiseEvent() typeof(IntegrationEvents) .GetField("ConnectionUpdated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); - - Action handler = (running, source, handle, serviceType) => + + Action handler = (conn) => { - if (source == SubathonEventSource.StreamElements) + if (conn.Source == SubathonEventSource.StreamElements) { eventRaised = true; - Assert.True(running); + Assert.True(conn.Status); } }; @@ -260,12 +261,12 @@ public void OnAuthenticateError_ShouldSetConnectedFalse_AndRaiseErrorEvent() ?.SetValue(null, null); bool eventRaised = false; - Action handler = (running, source, handle, service) => + Action handler = (conn) => { - if (source == SubathonEventSource.StreamElements) + if (conn.Source == SubathonEventSource.StreamElements) { eventRaised = true; - Assert.False(running); + Assert.False(conn.Status); } }; IntegrationEvents.ConnectionUpdated += handler; diff --git a/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs index d430f93..07422d9 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs @@ -9,6 +9,7 @@ using Streamlabs.SocketClient; using Streamlabs.SocketClient.Messages.DataTypes; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Objects; using SubathonManager.Tests.Utility; namespace SubathonManager.Tests.IntegrationUnitTests @@ -127,14 +128,20 @@ public async Task InitClientAsync_RaisesConnectionUpdate_TrueOnSuccess() var (service, _) = MakeService("valid_token"); bool? lastStatus = null; - IntegrationEvents.ConnectionUpdated += (b, _, _, svc) => + Action handler = (conn) => { - if (svc == "Socket") lastStatus = b; + if (conn is { Source: SubathonEventSource.StreamLabs, Service: "Socket" }) + { + lastStatus = conn.Status; + } }; + IntegrationEvents.ConnectionUpdated += handler; await service.InitClientAsync(); Assert.True(lastStatus); + IntegrationEvents.ConnectionUpdated -= handler; + Assert.True(Utils.GetConnection(SubathonEventSource.StreamLabs, "Socket").Status); } [Fact] @@ -159,14 +166,19 @@ public async Task InitClientAsync_RaisesConnectionUpdate_FalseOnFailure() .ThrowsAsync(new Exception("fail")); bool? lastStatus = null; - IntegrationEvents.ConnectionUpdated += (b, _, _, svc) => + Action handler = (conn) => { - if (svc == "Socket") lastStatus = b; + if (conn is { Source: SubathonEventSource.StreamLabs, Service: "Socket" }) + { + lastStatus = conn.Status; + } }; + IntegrationEvents.ConnectionUpdated += handler; await service.InitClientAsync(); Assert.False(lastStatus); + IntegrationEvents.ConnectionUpdated -= handler; } [Fact] @@ -194,15 +206,20 @@ public async Task DisconnectAsync_SetsConnectedFalse_AndRaisesUpdate() Assert.True(service.Connected); bool? lastStatus = null; - IntegrationEvents.ConnectionUpdated += (b, _, _, svc) => + Action handler = (conn) => { - if (svc == "Socket") lastStatus = b; + if (conn is { Source: SubathonEventSource.StreamLabs, Service: "Socket" }) + { + lastStatus = conn.Status; + } }; + IntegrationEvents.ConnectionUpdated += handler; await service.DisconnectAsync(); Assert.False(service.Connected); Assert.False(lastStatus); + IntegrationEvents.ConnectionUpdated -= handler; mockClient.Verify(c => c.DisconnectAsync(), Times.Once); } diff --git a/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs index 3d80dcd..22e256a 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs @@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; +using SubathonManager.Core.Objects; using SubathonManager.Tests.Utility; using UserType = TwitchLib.Client.Enums.UserType; // ReSharper disable NullableWarningSuppressionIsUsed @@ -1188,9 +1189,9 @@ public async Task EventSub_ConnectsAndFiresConnectionUpdate() var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - void Handler(bool b, SubathonEventSource source, string name, string svc) + void Handler(IntegrationConnection conn) { - if (svc == "EventSub") tcs.TrySetResult(b); + if (conn.Service == "EventSub") tcs.TrySetResult(conn.Status); } IntegrationEvents.ConnectionUpdated += Handler; @@ -1221,6 +1222,7 @@ void Handler(bool b, SubathonEventSource source, string name, string svc) finally { IntegrationEvents.ConnectionUpdated -= Handler; + Assert.True(Utils.GetConnection(SubathonEventSource.Twitch, "EventSub").Status); } } } diff --git a/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs index 50a64d5..cc0e78f 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs @@ -8,6 +8,7 @@ using YTLiveChat.Contracts.Models; using YTLiveChat.Contracts.Services; using System.Reflection; +using SubathonManager.Core.Objects; using SubathonManager.Tests.Utility; // ReSharper disable NullableWarningSuppressionIsUsed @@ -70,12 +71,12 @@ public void OnInitialPageLoaded_ShouldSetRunningTrueAndRaiseEvent() var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); bool eventRaised = false; - Action handler = (running, source, handle, service) => + Action handler = (conn) => { - if (source == SubathonEventSource.YouTube) + if (conn.Source == SubathonEventSource.YouTube) { eventRaised = true; - Assert.True(running); + Assert.True(conn.Status); } }; IntegrationEvents.ConnectionUpdated += handler; @@ -112,12 +113,12 @@ public void OnErrorOccurred_ShouldSetRunningFalseAndRaiseError() var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); bool eventRaised = false; - Action handler = (running, source, handle, service) => + Action handler = (conn) => { - if (source == SubathonEventSource.YouTube) + if (conn.Source == SubathonEventSource.YouTube) { eventRaised = true; - Assert.False(running); + Assert.False(conn.Status); } }; @@ -319,12 +320,12 @@ public void OnChatStopped_ShouldSetRunningFalseAndRaiseEvent() var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); bool eventRaised = false; - Action handler = (running, source, handle, service) => + Action handler = (conn) => { - if (source == SubathonEventSource.YouTube) + if (conn.Source == SubathonEventSource.YouTube) { eventRaised = true; - Assert.False(running); + Assert.False(conn.Status); } }; IntegrationEvents.ConnectionUpdated += handler; @@ -359,12 +360,12 @@ public async Task Start_ShouldReturnTrueAndSetHandle() bool eventRaised = false; bool ranNone = false; - Action handler = (running, source, handle, serviceType) => + Action handler = (conn) => { - if (source != SubathonEventSource.YouTube) return; + if (conn.Source != SubathonEventSource.YouTube) return; if (!ranNone) { - Assert.Equal("None", handle); + Assert.Equal("None", conn.Name); ranNone = true; eventRaised = true; return; @@ -372,9 +373,11 @@ public async Task Start_ShouldReturnTrueAndSetHandle() Assert.True(ranNone); eventRaised = true; - Assert.False(running); - Assert.Equal("@TestChannel", handle); - Assert.True(service.Running); + Assert.False(conn.Status); + Assert.Equal("@TestChannel", conn.Name); + Assert.Equal("@TestChannel", Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Name); + Assert.False(Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Status); + Assert.False(service.Running); }; IntegrationEvents.ConnectionUpdated += handler; @@ -383,8 +386,10 @@ public async Task Start_ShouldReturnTrueAndSetHandle() //bool result = service.Start(null); await service.StartAsync(CancellationToken.None); Assert.False(service.Running); // happens during events - await Task.Delay(100); + await Task.Delay(300); Assert.True(eventRaised); + Assert.Equal("@TestChannel", Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Name); + Assert.False(Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Status); await service.StopAsync(CancellationToken.None); } finally @@ -528,10 +533,13 @@ public void OnChatReceived_ShouldRaiseConnectionUpdate_WhenNotRunning() service.Running = false; // bool connectionEventRaised = false; - Action handler = (running, source, handle, svc) => + + Action handler = (conn) => { - if (source == SubathonEventSource.YouTube && running) + if (conn is { Source: SubathonEventSource.YouTube }) + { connectionEventRaised = true; + } }; IntegrationEvents.ConnectionUpdated += handler; diff --git a/SubathonManager.UI/App.Subathon.xaml.cs b/SubathonManager.UI/App.Subathon.xaml.cs index 5635dd1..106755a 100644 --- a/SubathonManager.UI/App.Subathon.xaml.cs +++ b/SubathonManager.UI/App.Subathon.xaml.cs @@ -1,13 +1,18 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using SubathonManager.Core.Events; using SubathonManager.Core; +using SubathonManager.Core.Models; using SubathonManager.Data; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.UI; public partial class App { + private record SubathonTickState(Guid Id, bool IsPaused, bool IsReversed, double MultiplierValue, DateTime? MultiplierExpiry); + private SubathonTickState? _cachedTickState; public static async void InitSubathonTimer() { @@ -17,62 +22,83 @@ public static async void InitSubathonTimer() if (subathon != null) SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); } + private void UpdateTickStateCache(SubathonData data, DateTime _) + { + _cachedTickState = new SubathonTickState( + data.Id, + data.IsPaused, + data.IsSubathonReversed(), + data.Multiplier.Multiplier, + data.Multiplier.Duration == null ? null : data.Multiplier.Started + data.Multiplier.Duration + ); + } + private async void UpdateSubathonTimers(TimeSpan time) { - await using var db = await _factory!.CreateDbContextAsync(); - var subathon = await db.SubathonDatas.Include(s=> s.Multiplier) - .SingleOrDefaultAsync(x => x.IsActive &&(!x.IsPaused || - x.Multiplier.Multiplier < 1 || x.Multiplier.Multiplier > 1)); - - if (subathon == null) return; - int ran = -1; - if (subathon.IsSubathonReversed()) - { - ran = await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET MillisecondsElapsed = MillisecondsElapsed + {0} WHERE IsActive = 1 AND IsPaused = 0 AND MillisecondsElapsed + MillisecondsCumulative > 0", - time.TotalMilliseconds); - } - else + try { - ran = await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET MillisecondsElapsed = MillisecondsElapsed + {0} WHERE IsActive = 1 AND IsPaused = 0 AND MillisecondsCumulative - MillisecondsElapsed > 0", - time.TotalMilliseconds); - } + var state = _cachedTickState; + if (state is null or { IsPaused: true, MultiplierValue: 1 }) return; - if (ran == 0) - { - // try to set to equal 0 if it would go negative - if (subathon.IsSubathonReversed()) + await using var db = await _factory!.CreateDbContextAsync(); + + int ran = -1; + if (state.IsReversed) { - await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET MillisecondsElapsed = -MillisecondsCumulative WHERE IsActive = 1 AND IsPaused = 0 AND MillisecondsElapsed + MillisecondsCumulative + 1000 <= 0"); + ran = await db.Database.ExecuteSqlRawAsync( + "UPDATE SubathonDatas SET MillisecondsElapsed = MillisecondsElapsed + {0} WHERE IsActive = 1 AND IsPaused = 0 AND MillisecondsElapsed + MillisecondsCumulative > 0", + time.TotalMilliseconds); } - else + { + ran = await db.Database.ExecuteSqlRawAsync( + "UPDATE SubathonDatas SET MillisecondsElapsed = MillisecondsElapsed + {0} WHERE IsActive = 1 AND IsPaused = 0 AND MillisecondsCumulative - MillisecondsElapsed > 0", + time.TotalMilliseconds); + } + + if (ran == 0) + { + // try to set to equal 0 if it would go negative + if (state.IsReversed) + { + await db.Database.ExecuteSqlRawAsync( + "UPDATE SubathonDatas SET MillisecondsElapsed = -MillisecondsCumulative WHERE IsActive = 1 AND IsPaused = 0 AND MillisecondsElapsed + MillisecondsCumulative + 1000 <= 0"); + } + else + { + await db.Database.ExecuteSqlRawAsync( + "UPDATE SubathonDatas SET MillisecondsElapsed = MillisecondsCumulative WHERE IsActive = 1 AND IsPaused = 0 AND MillisecondsCumulative - MillisecondsElapsed - 1000 <= 0"); + } + } + + if (state.MultiplierExpiry != null && !state.MultiplierValue.Equals(1) && DateTime.Now >= state.MultiplierExpiry) { await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET MillisecondsElapsed = MillisecondsCumulative WHERE IsActive = 1 AND IsPaused = 0 AND MillisecondsCumulative - MillisecondsElapsed - 1000 <= 0"); + "UPDATE MultiplierDatas SET Multiplier = 1, Duration = null WHERE SubathonId = {0}", + state.Id); } - } - - await db.Entry(subathon).ReloadAsync(); + + var snapshot = await db.SubathonDatas + .Where(x => x.Id == state.Id && x.IsActive) + .Include(x => x.Multiplier) + .AsNoTracking() + .FirstOrDefaultAsync(); - if (subathon.TimeRemainingRounded().TotalSeconds <= 0 && !subathon.IsPaused) - { - await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET IsLocked = 1 WHERE IsActive = 1 AND IsPaused = 0 AND Id = {0}", - subathon.Id); + if (snapshot == null) return; + + if (snapshot.TimeRemainingRounded().TotalSeconds <= 0 && snapshot is { IsPaused: false, IsLocked: false }) + { + await db.Database.ExecuteSqlRawAsync( + "UPDATE SubathonDatas SET IsLocked = 1 WHERE IsActive = 1 AND IsPaused = 0 AND Id = {0}", + snapshot.Id); + snapshot.IsLocked = true; + } + + SubathonEvents.RaiseSubathonDataUpdate(snapshot, DateTime.Now); } - - if (subathon.Multiplier.Duration != null && (subathon.Multiplier.Multiplier < 1 || subathon.Multiplier.Multiplier > 1) - && DateTime.Now >= subathon.Multiplier.Started + subathon.Multiplier.Duration) + catch (Exception e) { - await db.Database.ExecuteSqlRawAsync("UPDATE MultiplierDatas SET Multiplier = 1, Duration = null WHERE SubathonId = {0}", - subathon.Id); + _logger?.LogError(e, "Failed to tick down timer"); } - - await db.Entry(subathon).ReloadAsync(); - db.Entry(subathon).State = EntityState.Detached; - db.Entry(subathon.Multiplier).State = EntityState.Detached; - SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); } } \ No newline at end of file diff --git a/SubathonManager.UI/App.xaml.cs b/SubathonManager.UI/App.xaml.cs index ac2fe7b..faafef4 100644 --- a/SubathonManager.UI/App.xaml.cs +++ b/SubathonManager.UI/App.xaml.cs @@ -14,6 +14,7 @@ using SubathonManager.Services; using SubathonManager.UI.Services; using Wpf.Ui.Markup; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.UI; @@ -67,9 +68,9 @@ protected override void OnStartup(StartupEventArgs e) bool bitsAsDonationCheck = config.GetBool("Currency", "BitsLikeAsDonation", false); Utils.DonationSettings["BitsLikeAsDonation"] = bitsAsDonationCheck; - foreach (var goAffProSource in Enum.GetNames()) + foreach (var goAffProSource in Enum.GetValues().Where(ga => ga != GoAffProSource.Unknown && !ga.IsDisabled())) { - Utils.DonationSettings[goAffProSource] = + Utils.DonationSettings[$"{goAffProSource}"] = config.GetBool("GoAffPro", $"{goAffProSource}.CommissionAsDonation", false); } @@ -89,6 +90,8 @@ protected override void OnStartup(StartupEventArgs e) } base.OnStartup(e); + + SubathonEvents.SubathonDataUpdate += UpdateTickStateCache; var sm = AppServices.Provider.GetRequiredService(); @@ -236,12 +239,12 @@ private void ConfigChanged(object sender, FileSystemEventArgs e) Utils.DonationSettings["BitsLikeAsDonation"] = bitsAsDonationCheck; } - foreach (var goAffProSource in Enum.GetNames()) + foreach (var goAffProSource in Enum.GetValues().Where(ga => ga != GoAffProSource.Unknown && !ga.IsDisabled())) { bool asDonation = config.GetBool("GoAffPro", $"{goAffProSource}.CommissionAsDonation", false); if (Utils.DonationSettings.TryGetValue($"{goAffProSource}", out bool hasVal) && hasVal == asDonation) continue; optionToggled = true; - Utils.DonationSettings[goAffProSource] = asDonation; + Utils.DonationSettings[$"{goAffProSource}"] = asDonation; } if (currencyChanged || optionToggled) @@ -314,7 +317,12 @@ private async Task SetupSubathonCurrencyData(AppDbContext db, bool? optionToggle { var amt = await currencyService.ConvertAsync((double)subathon.MoneySum, oldCurrency, currency); await db.UpdateSubathonMoney(amt, subathon.Id); - if (optionToggled != null && !(bool)optionToggled) return; + if (optionToggled != null && !(bool)optionToggled) { + var subathonTotals = await EventService.GetSubathonTotalsAsync(db); + if (subathonTotals != null) + SubathonEvents.RaiseSubathonTotalsUpdated(subathonTotals); + return; + } } // reconvert everything, rarely called, unless toggling bits as donations @@ -332,7 +340,7 @@ private async Task SetupSubathonCurrencyData(AppDbContext db, bool? optionToggle foreach (var e in events) { (bool isBitsLike, double modifier) = Utils.GetAltCurrencyUseAsDonation(config, e.EventType); - if (e.EventType.IsCheerType() && isBitsLike) + if (e.EventType.IsToken() && isBitsLike) { bits += (int.Parse(e.Value) * modifier); continue; @@ -340,7 +348,7 @@ private async Task SetupSubathonCurrencyData(AppDbContext db, bool? optionToggle if (string.IsNullOrWhiteSpace(e.Currency)) continue; var value = e.Value; var curr = e.Currency; - if (e.EventType.IsOrderType()) + if (e.EventType.IsOrder()) { value = e.SecondaryValue.Split('|')[0]; curr = e.SecondaryValue.Split('|')[1]; @@ -355,6 +363,9 @@ private async Task SetupSubathonCurrencyData(AppDbContext db, bool? optionToggle sum += val; } await db.UpdateSubathonMoney(sum, subathon.Id); + var totals = await EventService.GetSubathonTotalsAsync(db); + if (totals != null) + SubathonEvents.RaiseSubathonTotalsUpdated(totals); } } \ No newline at end of file diff --git a/SubathonManager.UI/Converters/Converters.cs b/SubathonManager.UI/Converters/Converters.cs index eab251e..ce19a02 100644 --- a/SubathonManager.UI/Converters/Converters.cs +++ b/SubathonManager.UI/Converters/Converters.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Text.RegularExpressions; using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; namespace SubathonManager.UI.Converters { @@ -40,6 +41,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture { return type.IsControlTypeCommand() ? Visibility.Hidden : Visibility.Visible; } + return Visibility.Visible; } @@ -52,7 +54,7 @@ public class InverseBoolToVisibilityConverter : IValueConverter public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { bool b = value as bool? ?? false; - return b ? Visibility.Collapsed : Visibility.Visible; + return b ? Visibility.Collapsed : Visibility.Visible; } public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) @@ -65,17 +67,32 @@ public object Convert(object? value, Type targetType, object? parameter, Culture { if (value is SubathonCommandType type) { - return type is SubathonCommandType.None or SubathonCommandType.Unknown - ? Visibility.Visible : Visibility.Collapsed; + return type is SubathonCommandType.None or SubathonCommandType.Unknown + ? Visibility.Visible + : Visibility.Collapsed; } return Visibility.Visible; } - + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException(); } + public class AmountFormatConverter : IMultiValueConverter + { + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values[0] is not int amount) return string.Empty; + if (values[1] is SubathonEventType eventType && ((SubathonEventType?)eventType).IsOrder()) + return $"(x{amount} items)"; + return $"x{amount}"; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => throw new NotImplementedException(); + } + public class GreaterThanOneToVisibilityConverter : IValueConverter { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) @@ -84,6 +101,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture if (value is int i) return i > 1 ? Visibility.Visible : Visibility.Collapsed; return Visibility.Collapsed; } + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException(); } @@ -96,6 +114,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture if (value is int i) return i > 0 ? Visibility.Visible : Visibility.Collapsed; return Visibility.Collapsed; } + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException(); } @@ -106,7 +125,7 @@ public class EventTypeValueConverter : IMultiValueConverter { if (values.Length == 0) return null; if (values.Length < 3) return values[0]; - + var val = values[0]?.ToString(); var type = ""; var curr = values[2]?.ToString() ?? ""; @@ -114,8 +133,8 @@ public class EventTypeValueConverter : IMultiValueConverter { type = eventType == SubathonEventType.TwitchRaid ? "viewers" : curr; } - - + + if (curr == "sub") { val = val switch @@ -129,10 +148,11 @@ public class EventTypeValueConverter : IMultiValueConverter return string.IsNullOrEmpty(type.Trim()) ? val! : $"{val} {type}"; } + public object[] ConvertBack(object value, Type[] targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException(); } - + public partial class CssColorStringToColorConverter : IValueConverter { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) @@ -152,7 +172,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture : (byte)255; return Color.FromArgb(a, r, g, b); } - + return (Color)ColorConverter.ConvertFromString(str); } catch @@ -169,13 +189,15 @@ public object ConvertBack(object? value, Type targetType, object? parameter, Cul return $"#{c.R:X2}{c.G:X2}{c.B:X2}"; return $"rgba({c.R},{c.G},{c.B},{c.A / 255.0:F2})"; } + return string.Empty; } - [GeneratedRegex(@"rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)", RegexOptions.IgnoreCase, "en-CA")] + [GeneratedRegex(@"rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)", RegexOptions.IgnoreCase, + "en-CA")] private static partial Regex IsRgbaColourParseRegex(); } - + public class CssVariableTypeOptionsConverter : IValueConverter { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) @@ -188,7 +210,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException(); } - + public partial class CssSizeValueConverter : IValueConverter { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) @@ -200,6 +222,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException(); + [GeneratedRegex(@"^-?[\d.]+")] private static partial Regex IsNumberRegex(); } @@ -215,11 +238,11 @@ public object Convert(object? value, Type targetType, object? parameter, Culture public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException(); - + [GeneratedRegex(@"[a-zA-Z%]+$")] private static partial Regex SizeUnitRegex(); } - + public class NullOrEmptyToNullConverter : IValueConverter { public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) @@ -228,12 +251,51 @@ public class NullOrEmptyToNullConverter : IValueConverter public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException(); } - + public class StringToBoolConverter : IValueConverter { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => bool.TryParse(value as string, out var b) && b; + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => (value is bool ? value.ToString() : "False") ?? "False"; } + + public class EnumDescriptionConverter : IValueConverter + { + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is Enum e) + return EnumMetaCache.Get(e)?.Description ?? e.ToString(); + + return value?.ToString() ?? ""; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); + } + + + public class VarTooltipConverter : IValueConverter + { + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + switch (value) + { + case CssVariable e: + { + var type = e.Type == WidgetCssVariableType.Default ? WidgetCssVariableType.String : e.Type; + var description = string.IsNullOrWhiteSpace(e.Description) ? "" : $"\n{e.Description}"; + return $"{e.Name}\nType: {type}{description}"; + } + case JsVariable r: + return $"{r.Name}\nType: {r.Type}"; + default: + return value?.ToString() ?? ""; + } + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/SubathonManager.UI/EditRouteWindow.Handlers.cs b/SubathonManager.UI/EditRouteWindow.Handlers.cs index 597e199..31d4e27 100644 --- a/SubathonManager.UI/EditRouteWindow.Handlers.cs +++ b/SubathonManager.UI/EditRouteWindow.Handlers.cs @@ -1,10 +1,8 @@ -using System.Collections.ObjectModel; -using System.Diagnostics; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Windows; using System.Windows.Controls; -using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Threading; @@ -120,7 +118,9 @@ private void ReloadVars_Click(object sender, RoutedEventArgs e) .FirstOrDefault(wX => wX.Id == _selectedWidget.Id); _selectedWidget = widget; - _editingCssVars = new ObservableCollection(_selectedWidget!.CssVariables); + CssVarsList.ItemsSource = null; + _editingCssVars.Clear(); + foreach (var v in _selectedWidget!.CssVariables) _editingCssVars.Add(v); CssVarsList.ItemsSource = _editingCssVars; PopulateJsVars(); } @@ -164,9 +164,16 @@ private async void SaveWidgetButton_Click(object sender, RoutedEventArgs e) foreach(var cssVar in _selectedWidget.CssVariables) _editingCssVars.Add(cssVar); - await LoadRouteAsync(); + // await LoadRouteAsync(); RefreshWebView(); - + var listEntry = _widgets.FirstOrDefault(wi => wi.Id == _selectedWidget.Id); + if (listEntry != null) + { + // listEntry.Name = _selectedWidget.Name; + int index = _widgets.IndexOf(listEntry); + _widgets.Remove(listEntry); + _widgets.Insert(index, _selectedWidget); + } WidgetsList.Items.Refresh(); OverlayEvents.RaiseOverlayRefreshRequested(_selectedWidget.RouteId); @@ -497,6 +504,8 @@ void Attach() { switch (sender) { + case Expander: + break; case TextBox tb: tb.TextChanged += Value_OnChanged; break; @@ -507,10 +516,6 @@ void Attach() chk.Checked += Value_OnChanged; chk.Unchecked += Value_OnChanged; break; - case ToggleButton tb2: - tb2.Checked += Value_OnChanged; - tb2.Unchecked += Value_OnChanged; - break; case Slider sld: sld.ValueChanged += Value_OnChanged; break; @@ -524,7 +529,38 @@ void Attach() private void Value_OnChanged(object sender, RoutedEventArgs e) { if (_suppressCount > 0) return; - Dispatcher.Invoke( () => UpdateSaveButtonBorder(SaveButtonBorder, true)); + UpdateSaveButtonBorder(SaveButtonBorder, true); + } + + + private void IntBox_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not System.Windows.Controls.TextBox tb) return; + tb.PreviewTextInput += (s, ev) => + { + var box = (System.Windows.Controls.TextBox)s; + if (char.IsDigit(ev.Text, 0) || ev.Text == "-" && box.SelectionStart == 0 && !box.Text.Contains('-')) + ev.Handled = false; + else + ev.Handled = true; + }; + AttachChangeHandler(sender, e); + } + + private void FloatBox_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not System.Windows.Controls.TextBox tb) return; + tb.PreviewTextInput += (s, ev) => + { + var box = (System.Windows.Controls.TextBox)s; + if (char.IsDigit(ev.Text, 0) || + ev.Text == "-" && box.SelectionStart == 0 && !box.Text.Contains('-') || + ev.Text == "." && !box.Text.Contains('.')) + ev.Handled = false; + else + ev.Handled = true; + }; + AttachChangeHandler(sender, e); } #endregion GeneralHandlers @@ -547,11 +583,9 @@ private void SizeValueBox_TextChanged(object sender, TextChangedEventArgs e) private void SizeUnitBox_Loaded(object sender, RoutedEventArgs e) { - if (sender is ComboBox cb) - { - cb.SelectionChanged += SizeUnitBox_SelectionChanged; - AttachChangeHandler(sender, e); - } + if (sender is not ComboBox cb) return; + cb.SelectionChanged += SizeUnitBox_SelectionChanged; + AttachChangeHandler(sender, e); } private void SizeUnitBox_SelectionChanged(object sender, SelectionChangedEventArgs e) @@ -571,47 +605,70 @@ private void SizeUnitBox_SelectionChanged(object sender, SelectionChangedEventAr if (tb.Parent is not Panel parent) return null; return parent.Children.OfType().FirstOrDefault(); } -#endregion CSSHandlers - -#region JSHandlers - private void JsIntBox_Loaded(object sender, RoutedEventArgs e) + + + private void OpacitySlider_Loaded(object sender, RoutedEventArgs e) { - if (sender is not System.Windows.Controls.TextBox tb) return; - tb.PreviewTextInput += (s, ev) => + if (sender is not Slider { Tag: CssVariable cssVar } slider) return; + if (float.TryParse(cssVar.Value, out var initial)) + slider.Value = initial; + + void OpacitySliderValueChanged(object o, RoutedPropertyChangedEventArgs args) { - var box = (System.Windows.Controls.TextBox)s; - if (char.IsDigit(ev.Text, 0) || ev.Text == "-" && box.SelectionStart == 0 && !box.Text.Contains('-')) - ev.Handled = false; - else - ev.Handled = true; - }; + var floatVal = (float)args.NewValue; + cssVar.Value = floatVal.ToString(CultureInfo.InvariantCulture); + if (FindPercentSiblingBox(slider) is { } tb && tb.Text != floatVal.ToString(CultureInfo.InvariantCulture)) + tb.Text = floatVal.ToString(CultureInfo.InvariantCulture); + } + + slider.ValueChanged += OpacitySliderValueChanged; AttachChangeHandler(sender, e); } - private void JsFloatBox_Loaded(object sender, RoutedEventArgs e) + private void OpacityBox_Loaded(object sender, RoutedEventArgs e) { - if (sender is not System.Windows.Controls.TextBox tb) return; - tb.PreviewTextInput += (s, ev) => + if (sender is not System.Windows.Controls.TextBox { Tag: CssVariable cssVar } tb) return; + tb.Text = float.TryParse(cssVar.Value, out var initial) ? initial.ToString(CultureInfo.InvariantCulture) : "0"; + + tb.PreviewTextInput += (_, ev) => { - var box = (System.Windows.Controls.TextBox)s; - if (char.IsDigit(ev.Text, 0) || - ev.Text == "-" && box.SelectionStart == 0 && !box.Text.Contains('-') || - ev.Text == "." && !box.Text.Contains('.')) - ev.Handled = false; - else + var newText = tb.Text.Remove(tb.SelectionStart, tb.SelectionLength) + .Insert(tb.SelectionStart, ev.Text); + if (!float.TryParse(newText, out var val) || val < 0 || val > 1) ev.Handled = true; }; + + tb.TextChanged += (_, __) => + { + if (string.IsNullOrWhiteSpace(tb.Text)) return; + if (!float.TryParse(tb.Text, out var val)) return; + val = Math.Clamp(val, 0, 1); + cssVar.Value = val.ToString(CultureInfo.InvariantCulture); + if (FindPercentSiblingSlider(tb) is { } slider && Math.Abs((float)slider.Value - val) > 0.001) + slider.Value = val; + }; AttachChangeHandler(sender, e); } +#endregion CSSHandlers + +#region JSHandlers private void JsBoolBox_Loaded(object sender, RoutedEventArgs e) { if (sender is not CheckBox { Tag: JsVariable jsVar } cb) return; - cb.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () => + + void BoxChecked(object o, RoutedEventArgs routedEventArgs) { - cb.Checked += (_, __) => jsVar.Value = "True"; - cb.Unchecked += (_, __) => jsVar.Value = "False"; - }); + jsVar.Value = "True"; + } + + void BoxUnchecked(object o, RoutedEventArgs routedEventArgs) + { + jsVar.Value = "False"; + } + + cb.Checked += BoxChecked; + cb.Unchecked += BoxUnchecked; AttachChangeHandler(sender, e); } @@ -619,11 +676,11 @@ private void JsEventTypeSelectBox_Loaded(object sender, RoutedEventArgs e) { if (sender is not ComboBox { Tag: JsVariable jsVar } cb) return; var values = Enum.GetValues() - .Where(x => ((SubathonEventType?)x).IsEnabled()) + .Where(x => x.IsEnabled()) .Where(x => ((SubathonEventType?)x).HasNoValueConfig()) - .Select(x => x.ToString()).OrderBy(x => x); + .Select(x => x).OrderBy(x => x.GetOrderNumber()); cb.Items.Add(string.Empty); - foreach (var val in values) cb.Items.Add(val); + foreach (var val in values) cb.Items.Add(val.ToString()); cb.SelectedValue = string.IsNullOrWhiteSpace(jsVar.Value) ? string.Empty : jsVar.Value; cb.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () => { @@ -638,9 +695,9 @@ private void JsEventSubTypeSelectBox_Loaded(object sender, RoutedEventArgs e) if (sender is not ComboBox { Tag: JsVariable jsVar } cb) return; var values = Enum.GetValues() .Where(x => ((SubathonEventSubType?)x).IsTrueEvent()) - .Select(x => x.ToString()).OrderBy(x => x); + .Select(x => x).OrderBy(x => x.GetOrderNumber()); cb.Items.Add(string.Empty); - foreach (var val in values) cb.Items.Add(val); + foreach (var val in values) cb.Items.Add(val.ToString()); cb.SelectedValue = string.IsNullOrWhiteSpace(jsVar.Value) ? string.Empty : jsVar.Value; cb.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () => { @@ -660,7 +717,7 @@ private void JsStringSelectBox_Loaded(object sender, RoutedEventArgs e) cb.SelectionChanged += (_, __) => { if (!jsVar.Value?.Contains(',') ?? true) return; - if (jsVar.Value.StartsWith($"{cb.SelectedValue},")) return; + if (jsVar.Value!.StartsWith($"{cb.SelectedValue},")) return; var newVal = new List { $"{cb.SelectedValue}" }; foreach (var v in values) if (!newVal.Contains(v)) newVal.Add(v); @@ -733,6 +790,7 @@ private void JsFileVar_Loaded(object sender, RoutedEventArgs e) private void JsEventTypeList_Loaded(object sender, RoutedEventArgs e) { if (sender is not Expander { Tag: JsVariable jsVar } expander) return; + if (expander.Content != null) return; var panelValues = (jsVar.Value ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) @@ -740,11 +798,11 @@ private void JsEventTypeList_Loaded(object sender, RoutedEventArgs e) var outerPanel = new StackPanel { Orientation = Orientation.Vertical }; var groupValues = Enum.GetValues() - .Where(x => ((SubathonEventType?)x).IsEnabled()) + .Where(x => x.IsEnabled()) .Where(x => x is not SubathonEventType.Command and not SubathonEventType.Unknown) - .GroupBy(x => ((SubathonEventType?)x).GetSource()) + .GroupBy(x => x.GetSource()) .OrderBy(g => SubathonEventSourceHelper.GetSourceOrder(g.Key)) - .ThenBy(g => g.Key.ToString()); + .ThenBy(g => g.Key.GetOrderNumber()); foreach (var group in groupValues) { @@ -756,12 +814,12 @@ private void JsEventTypeList_Loaded(object sender, RoutedEventArgs e) }; var chkboxList = new StackPanel { Orientation = Orientation.Vertical }; - foreach (var eType in group.Select(x => x.ToString()).OrderBy(x => x)) + foreach (var eType in group.Select(x => x).OrderBy(x => x.GetOrderNumber())) { var chkBox = new CheckBox { - Content = new Wpf.Ui.Controls.TextBlock { Text = eType, TextWrapping = TextWrapping.Wrap, MaxWidth = 240 }, - IsChecked = panelValues.Contains(eType), + Content = new Wpf.Ui.Controls.TextBlock { Text = ((SubathonEventType?)eType).GetLabel(), Tag=eType, TextWrapping = TextWrapping.Wrap, MaxWidth = 240 }, + IsChecked = panelValues.Contains(eType.ToString()), Margin = new Thickness(2) }; chkBox.Checked += (_, __) => UpdateEventListValues(jsVar, outerPanel); @@ -778,19 +836,20 @@ private void JsEventTypeList_Loaded(object sender, RoutedEventArgs e) private void JsEventSubTypeList_Loaded(object sender, RoutedEventArgs e) { if (sender is not Expander { Tag: JsVariable jsVar } expander) return; + if (expander.Content != null) return; var panelValues = (jsVar.Value ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToHashSet(StringComparer.OrdinalIgnoreCase); var chkboxList = new StackPanel { Orientation = Orientation.Vertical }; - foreach (var eType in Enum.GetNames().OrderBy(x => x)) + foreach (var eType in Enum.GetValues().OrderBy(x => x.GetOrderNumber())) { - if (eType is nameof(SubathonEventSubType.CommandLike) or nameof(SubathonEventSubType.Unknown)) continue; + if (eType is SubathonEventSubType.CommandLike or SubathonEventSubType.Unknown) continue; var chkBox = new CheckBox { - Content = new Wpf.Ui.Controls.TextBlock { Text = eType, TextWrapping = TextWrapping.Wrap, MaxWidth = 278 }, - IsChecked = panelValues.Contains(eType), + Content = new Wpf.Ui.Controls.TextBlock { Text = eType.GetDescription(), Tag=eType, TextWrapping = TextWrapping.Wrap, MaxWidth = 278 }, + IsChecked = panelValues.Contains(eType.ToString()), Margin = new Thickness(2) }; chkBox.Checked += (_, __) => UpdateEventListValues(jsVar, chkboxList); @@ -845,6 +904,55 @@ private void JsPercentBox_Loaded(object sender, RoutedEventArgs e) }; AttachChangeHandler(sender, e); } + + private void JsFilteredEventTypeList_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not Expander { Tag: JsVariable jsVar } expander) return; + if (expander.Content != null) return; + + var allowedTypes = jsVar.Type.GetFilteredEventTypes().ToHashSet(); + + var panelValues = (jsVar.Value ?? "").Split(',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var outerPanel = new StackPanel { Orientation = Orientation.Vertical }; + var groupValues = Enum.GetValues() + .Where(x => allowedTypes.Contains(x)) + .Where(x => x.IsEnabled()) + .Where(x => x is not SubathonEventType.Command and not SubathonEventType.Unknown) + .GroupBy(x => x.GetSource()) + .OrderBy(g => SubathonEventSourceHelper.GetSourceOrder(g.Key)) + .ThenBy(g => g.Key.GetOrderNumber()); + + foreach (var group in groupValues) + { + var groupExpander = new Expander + { + BorderBrush = Brushes.DarkGray, BorderThickness = new Thickness(0, 0, 0, 1), + Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(0, 2, 0, 2), + IsExpanded = false, Header = group.Key.ToString() + }; + + var chkboxList = new StackPanel { Orientation = Orientation.Vertical }; + foreach (var eType in group.Select(x => x).OrderBy(x => x.GetOrderNumber())) + { + var chkBox = new CheckBox + { + Content = new Wpf.Ui.Controls.TextBlock { Text = ((SubathonEventType?)eType).GetLabel(), Tag=eType, TextWrapping = TextWrapping.Wrap, MaxWidth = 240 }, + IsChecked = panelValues.Contains(eType.ToString()), + Margin = new Thickness(2) + }; + chkBox.Checked += (_, __) => UpdateEventListValues(jsVar, outerPanel); + chkBox.Unchecked += (_, __) => UpdateEventListValues(jsVar, outerPanel); + chkboxList.Children.Add(chkBox); + AttachChangeHandler(chkBox, e); + } + groupExpander.Content = chkboxList; + outerPanel.Children.Add(groupExpander); + } + expander.Content = outerPanel; + } private Slider? FindPercentSiblingSlider(System.Windows.Controls.TextBox tb) { diff --git a/SubathonManager.UI/EditRouteWindow.xaml b/SubathonManager.UI/EditRouteWindow.xaml index 856a9e5..eed0d8a 100644 --- a/SubathonManager.UI/EditRouteWindow.xaml +++ b/SubathonManager.UI/EditRouteWindow.xaml @@ -21,6 +21,7 @@ +