Refactor and simplify member list code
This commit is contained in:
		
							
								
								
									
										164
									
								
								PluralKit.Bot/Commands/Lists/ContextListExt.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								PluralKit.Bot/Commands/Lists/ContextListExt.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,164 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| using DSharpPlus.Entities; | ||||
|  | ||||
| using Humanizer; | ||||
|  | ||||
| using NodaTime; | ||||
|  | ||||
| using PluralKit.Core; | ||||
|  | ||||
| namespace PluralKit.Bot | ||||
| { | ||||
|     public static class ContextListExt | ||||
|     { | ||||
|         public static MemberListOptions ParseMemberListOptions(this Context ctx, LookupContext lookupCtx) | ||||
|         { | ||||
|             var p = new MemberListOptions(); | ||||
|              | ||||
|             // Short or long list? (parse this first, as it can potentially take a positional argument) | ||||
|             var isFull = ctx.Match("f", "full", "big", "details", "long") || ctx.MatchFlag("f", "full"); | ||||
|             p.Type = isFull ? ListType.Long : ListType.Short; | ||||
|              | ||||
|             // Search query | ||||
|             if (ctx.HasNext()) | ||||
|                 p.Search = ctx.RemainderOrNull(); | ||||
|              | ||||
|             // Include description in search? | ||||
|             if (ctx.MatchFlag("search-description", "filter-description", "in-description", "sd", "description", "desc")) | ||||
|                 p.SearchDescription = true; | ||||
|  | ||||
|             // Sort property (default is by name, but adding a flag anyway, 'cause why not) | ||||
|             if (ctx.MatchFlag("by-name", "bn")) p.SortProperty = SortProperty.Name; | ||||
|             if (ctx.MatchFlag("by-display-name", "bdn")) p.SortProperty = SortProperty.DisplayName; | ||||
|             if (ctx.MatchFlag("by-id", "bid")) p.SortProperty = SortProperty.Hid; | ||||
|             if (ctx.MatchFlag("by-message-count", "bmc")) p.SortProperty = SortProperty.MessageCount; | ||||
|             if (ctx.MatchFlag("by-created", "bc")) p.SortProperty = SortProperty.CreationDate; | ||||
|             if (ctx.MatchFlag("by-last-fronted", "by-last-front", "by-last-switch", "blf", "bls")) p.SortProperty = SortProperty.LastSwitch; | ||||
|             if (ctx.MatchFlag("by-last-message", "blm", "blp")) p.SortProperty = SortProperty.LastMessage; | ||||
|             if (ctx.MatchFlag("by-birthday", "by-birthdate", "bbd")) p.SortProperty = SortProperty.Birthdate; | ||||
|              | ||||
|             // Sort reverse? | ||||
|             if (ctx.MatchFlag("r", "rev", "reverse")) | ||||
|                 p.Reverse = true; | ||||
|  | ||||
|             // Privacy filter (default is public only) | ||||
|             if (ctx.MatchFlag("a", "all")) p.PrivacyFilter = null;  | ||||
|             if (ctx.MatchFlag("private-only", "private", "priv")) p.PrivacyFilter = PrivacyLevel.Private; | ||||
|             if (ctx.MatchFlag("public-only", "public", "pub")) p.PrivacyFilter = PrivacyLevel.Public; | ||||
|  | ||||
|             // PERM CHECK: If we're trying to access non-public members of another system, error | ||||
|             if (p.PrivacyFilter != PrivacyLevel.Public && lookupCtx != LookupContext.ByOwner) | ||||
|                 // TODO: should this just return null instead of throwing or something? >.> | ||||
|                 throw new PKError("You cannot look up private members of another system."); | ||||
|              | ||||
|             // Additional fields to include in the search results | ||||
|             if (ctx.MatchFlag("with-last-switch", "with-last-fronted", "with-last-front", "wls", "wlf")) | ||||
|                 p.IncludeLastSwitch = true; | ||||
|             if (ctx.MatchFlag("with-last-message", "with-last-proxy", "wlm", "wlp")) | ||||
|                 p.IncludeLastMessage = true; | ||||
|             if (ctx.MatchFlag("with-message-count", "wmc")) | ||||
|                 p.IncludeMessageCount = true; | ||||
|             if (ctx.MatchFlag("with-created", "wc")) | ||||
|                 p.IncludeCreated = true; | ||||
|              | ||||
|             // Always show the sort property, too | ||||
|             if (p.SortProperty == SortProperty.LastSwitch) p.IncludeLastSwitch = true; | ||||
|             if (p.SortProperty == SortProperty.LastMessage) p.IncludeLastMessage= true; | ||||
|             if (p.SortProperty == SortProperty.MessageCount) p.IncludeMessageCount = true; | ||||
|             if (p.SortProperty == SortProperty.CreationDate) p.IncludeCreated = true; | ||||
|              | ||||
|             // Done! | ||||
|             return p; | ||||
|         } | ||||
|  | ||||
|         public static async Task RenderMemberList(this Context ctx, LookupContext lookupCtx, IDatabase db, SystemId system, string embedTitle, MemberListOptions opts) | ||||
|         { | ||||
|             // We take an IDatabase instead of a IPKConnection so we don't keep the handle open for the entire runtime | ||||
|             // We wanna release it as soon as the member list is actually *fetched*, instead of potentially minutes later (paginate timeout) | ||||
|             var members = (await db.Execute(conn => conn.QueryMemberList(system, opts.ToQueryOptions()))) | ||||
|                 .SortByMemberListOptions(opts, lookupCtx) | ||||
|                 .ToList(); | ||||
|  | ||||
|             var itemsPerPage = opts.Type == ListType.Short ? 25 : 5; | ||||
|             await ctx.Paginate(members.ToAsyncEnumerable(), members.Count, itemsPerPage, embedTitle, Renderer); | ||||
|  | ||||
|             // Base renderer, dispatches based on type | ||||
|             Task Renderer(DiscordEmbedBuilder eb, IEnumerable<ListedMember> page) | ||||
|             { | ||||
|                 // Add a global footer with the filter/sort string + result count | ||||
|                 eb.WithFooter($"{opts.CreateFilterString()}. {"result".ToQuantity(members.Count)}."); | ||||
|                  | ||||
|                 // Then call the specific renderers | ||||
|                 if (opts.Type == ListType.Short) | ||||
|                     ShortRenderer(eb, page); | ||||
|                 else | ||||
|                     LongRenderer(eb, page); | ||||
|                      | ||||
|                 return Task.CompletedTask; | ||||
|             } | ||||
|  | ||||
|             void ShortRenderer(DiscordEmbedBuilder eb, IEnumerable<ListedMember> page) | ||||
|             {   | ||||
|                 // We may end up over the description character limit | ||||
|                 // so run it through a helper that "makes it work" :) | ||||
|                 eb.WithSimpleLineContent(page.Select(m => | ||||
|                 { | ||||
|                     if (m.HasProxyTags) | ||||
|                     { | ||||
|                         var proxyTagsString = m.ProxyTagsString(); | ||||
|                         if (proxyTagsString.Length > 100) // arbitrary threshold for now, tweak? | ||||
|                             proxyTagsString = "tags too long, see member card"; | ||||
|                         return $"[`{m.Hid}`] **{m.NameFor(ctx)}** *(*{proxyTagsString}*)*"; | ||||
|                     } | ||||
|  | ||||
|                     return $"[`{m.Hid}`] **{m.NameFor(ctx)}**"; | ||||
|                 })); | ||||
|             } | ||||
|              | ||||
|             void LongRenderer(DiscordEmbedBuilder eb, IEnumerable<ListedMember> page) | ||||
|             { | ||||
|                 var zone = ctx.System?.Zone ?? DateTimeZone.Utc; | ||||
|                 foreach (var m in page) | ||||
|                 { | ||||
|                     var profile = new StringBuilder($"**ID**: {m.Hid}"); | ||||
|                      | ||||
|                     if (m.DisplayName != null && m.NamePrivacy.CanAccess(lookupCtx)) | ||||
|                         profile.Append($"\n**Display name**: {m.DisplayName}"); | ||||
|                      | ||||
|                     if (m.PronounsFor(lookupCtx) is {} pronouns) | ||||
|                         profile.Append($"\n**Pronouns**: {pronouns}"); | ||||
|                      | ||||
|                     if (m.BirthdayFor(lookupCtx) != null)  | ||||
|                         profile.Append($"\n**Birthdate**: {m.BirthdayString}"); | ||||
|                      | ||||
|                     if (m.ProxyTags.Count > 0)  | ||||
|                         profile.Append($"\n**Proxy tags:** {m.ProxyTagsString()}"); | ||||
|                      | ||||
|                     if (opts.IncludeMessageCount && m.MessageCountFor(lookupCtx) is {} count && count > 0) | ||||
|                         profile.Append($"\n**Message count:** {count}"); | ||||
|                      | ||||
|                     if (opts.IncludeLastMessage && m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg))  | ||||
|                         profile.Append($"\n**Last message:** {DiscordUtils.SnowflakeToInstant(lastMsg.Value).FormatZoned(zone)}"); | ||||
|                      | ||||
|                     if (opts.IncludeLastSwitch && m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw))  | ||||
|                         profile.Append($"\n**Last switched in:** {lastSw.Value.FormatZoned(zone)}"); | ||||
|  | ||||
|                     if (opts.IncludeCreated && m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created)) | ||||
|                         profile.Append($"\n**Created on:** {created.FormatZoned(zone)}"); | ||||
|                      | ||||
|                     if (m.DescriptionFor(lookupCtx) is {} desc)  | ||||
|                         profile.Append($"\n\n{desc}"); | ||||
|                      | ||||
|                     if (m.MemberVisibility == PrivacyLevel.Private) | ||||
|                         profile.Append("\n*(this member is hidden)*"); | ||||
|                      | ||||
|                     eb.AddField(m.NameFor(ctx), profile.ToString().Truncate(1024)); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										123
									
								
								PluralKit.Bot/Commands/Lists/MemberListOptions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								PluralKit.Bot/Commands/Lists/MemberListOptions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
|  | ||||
| using NodaTime; | ||||
|  | ||||
| using PluralKit.Core; | ||||
|  | ||||
| #nullable enable | ||||
| namespace PluralKit.Bot | ||||
| { | ||||
|     public class MemberListOptions | ||||
|     { | ||||
|         public SortProperty SortProperty { get; set; } = SortProperty.Name; | ||||
|         public bool Reverse { get; set; } | ||||
|  | ||||
|         public PrivacyLevel? PrivacyFilter { get; set; } = PrivacyLevel.Public; | ||||
|         public string? Search { get; set; } | ||||
|         public bool SearchDescription { get; set; } | ||||
|          | ||||
|         public ListType Type { get; set; } | ||||
|         public bool IncludeMessageCount { get; set; } | ||||
|         public bool IncludeLastSwitch { get; set; } | ||||
|         public bool IncludeLastMessage { get; set; } | ||||
|         public bool IncludeCreated { get; set; } | ||||
|          | ||||
|         public string CreateFilterString() | ||||
|         { | ||||
|             var str = new StringBuilder(); | ||||
|             str.Append("Sorting by "); | ||||
|             str.Append(SortProperty switch | ||||
|             { | ||||
|                 SortProperty.Name => "member name", | ||||
|                 SortProperty.Hid => "member ID", | ||||
|                 SortProperty.DisplayName => "display name", | ||||
|                 SortProperty.CreationDate => "creation date", | ||||
|                 SortProperty.LastMessage => "last message", | ||||
|                 SortProperty.LastSwitch => "last switch", | ||||
|                 SortProperty.MessageCount => "message count", | ||||
|                 SortProperty.Birthdate => "birthday", | ||||
|                 _ => new ArgumentOutOfRangeException($"Couldn't find readable string for sort property {SortProperty}") | ||||
|             }); | ||||
|              | ||||
|             if (Search != null) | ||||
|             { | ||||
|                 str.Append($", searching for \"{Search}\""); | ||||
|                 if (SearchDescription) str.Append(" (including description)"); | ||||
|             } | ||||
|  | ||||
|             str.Append(PrivacyFilter switch | ||||
|             { | ||||
|                 null => ", showing all members", | ||||
|                 PrivacyLevel.Private => ", showing only private members", | ||||
|                 PrivacyLevel.Public => "", // (default, no extra line needed) | ||||
|                 _ => new ArgumentOutOfRangeException($"Couldn't find readable string for privacy filter {PrivacyFilter}") | ||||
|             }); | ||||
|              | ||||
|             return str.ToString(); | ||||
|         } | ||||
|  | ||||
|         public DatabaseViewsExt.MemberListQueryOptions ToQueryOptions() => | ||||
|             new DatabaseViewsExt.MemberListQueryOptions | ||||
|             { | ||||
|                 PrivacyFilter = PrivacyFilter,  | ||||
|                 Search = Search, | ||||
|                 SearchDescription = SearchDescription | ||||
|             }; | ||||
|     } | ||||
|  | ||||
|     public static class MemberListOptionsExt | ||||
|     { | ||||
|         public static IEnumerable<ListedMember> SortByMemberListOptions(this IEnumerable<ListedMember> input, MemberListOptions opts, LookupContext ctx) | ||||
|         { | ||||
|             IComparer<T> ReverseMaybe<T>(IComparer<T> c) => | ||||
|                 opts.Reverse ? Comparer<T>.Create((a, b) => c.Compare(b, a)) : c; | ||||
|              | ||||
|             var culture = StringComparer.InvariantCultureIgnoreCase; | ||||
|             return (opts.SortProperty switch | ||||
|             { | ||||
|                 // As for the OrderByDescending HasValue calls: https://www.jerriepelser.com/blog/orderby-with-null-values/ | ||||
|                 // We want nulls last no matter what, even if orders are reversed | ||||
|                 SortProperty.Hid => input.OrderBy(m => m.Hid, ReverseMaybe(culture)), | ||||
|                 SortProperty.Name => input.OrderBy(m => m.NameFor(ctx), ReverseMaybe(culture)), | ||||
|                 SortProperty.CreationDate => input.OrderBy(m => m.Created, ReverseMaybe(Comparer<Instant>.Default)), | ||||
|                 SortProperty.MessageCount => input.OrderByDescending(m => m.MessageCount, ReverseMaybe(Comparer<int>.Default)), | ||||
|                 SortProperty.DisplayName => input | ||||
|                     .OrderByDescending(m => m.DisplayName != null) | ||||
|                     .ThenBy(m => m.DisplayName, ReverseMaybe(culture)), | ||||
|                 SortProperty.Birthdate => input | ||||
|                     .OrderByDescending(m => m.AnnualBirthday.HasValue) | ||||
|                     .ThenBy(m => m.AnnualBirthday, ReverseMaybe(Comparer<AnnualDate?>.Default)), | ||||
|                 SortProperty.LastMessage => input | ||||
|                     .OrderByDescending(m => m.LastMessage.HasValue) | ||||
|                     .ThenByDescending(m => m.LastMessage, ReverseMaybe(Comparer<ulong?>.Default)), | ||||
|                 SortProperty.LastSwitch => input | ||||
|                     .OrderByDescending(m => m.LastSwitchTime.HasValue) | ||||
|                     .ThenByDescending(m => m.LastSwitchTime, ReverseMaybe(Comparer<Instant?>.Default)), | ||||
|                 _ => throw new ArgumentOutOfRangeException($"Unknown sort property {opts.SortProperty}") | ||||
|             }) | ||||
|                 // Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values) | ||||
|                 .ThenBy(m => m.NameFor(ctx), culture); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     public enum SortProperty | ||||
|     { | ||||
|         Name, | ||||
|         DisplayName, | ||||
|         Hid, | ||||
|         MessageCount, | ||||
|         CreationDate, | ||||
|         LastSwitch, | ||||
|         LastMessage, | ||||
|         Birthdate | ||||
|     } | ||||
|  | ||||
|     public enum ListType | ||||
|     { | ||||
|         Short, | ||||
|         Long | ||||
|     } | ||||
| } | ||||
| @@ -1,4 +1,3 @@ | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| @@ -22,52 +21,23 @@ namespace PluralKit.Bot | ||||
|             if (target == null) throw Errors.NoSystemError; | ||||
|             ctx.CheckSystemPrivacy(target, target.MemberListPrivacy); | ||||
|  | ||||
|             // Must match full before calling the other flag parsers to make sure we consume the token before trying to match search terms, etc | ||||
|             var isFull = ctx.Match("f", "full", "big", "details", "long") || ctx.MatchFlag("f", "full"); | ||||
|             var opts = GetOptions(ctx, target); | ||||
|             var renderer = GetRendererFor(ctx, isFull, opts); | ||||
|  | ||||
|             var members = (await _db.Execute(c => opts.Execute(c, target, ctx.LookupContextFor(target)))).ToList(); | ||||
|             await ctx.Paginate( | ||||
|                 members.ToAsyncEnumerable(), | ||||
|                 members.Count, | ||||
|                 renderer.MembersPerPage, | ||||
|                 GetEmbedTitle(target, opts), | ||||
|                 (eb, ms) => | ||||
|                 { | ||||
|                     eb.WithFooter($"{opts.CreateFilterString()}. {members.Count} results."); | ||||
|                     renderer.RenderPage(eb, ctx.System?.Zone ?? DateTimeZone.Utc, ms, ctx.LookupContextFor(target)); | ||||
|                     return Task.CompletedTask; | ||||
|                 }); | ||||
|             var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(target)); | ||||
|             await ctx.RenderMemberList(ctx.LookupContextFor(target), _db, target.Id, GetEmbedTitle(target, opts), opts); | ||||
|         } | ||||
|  | ||||
|         private string GetEmbedTitle(PKSystem target, SortFilterOptions opts) | ||||
|         private string GetEmbedTitle(PKSystem target, MemberListOptions opts) | ||||
|         { | ||||
|             var title = new StringBuilder("Members of "); | ||||
|              | ||||
|             if (target.Name != null) title.Append($"{target.Name} (`{target.Hid}`)"); | ||||
|             else title.Append($"`{target.Hid}`"); | ||||
|             if (target.Name != null)  | ||||
|                 title.Append($"{target.Name} (`{target.Hid}`)"); | ||||
|             else  | ||||
|                 title.Append($"`{target.Hid}`"); | ||||
|   | ||||
|             if (opts.Filter != null) title.Append($" matching **{opts.Filter}**"); | ||||
|             if (opts.Search != null) | ||||
|                 title.Append($" matching **{opts.Search}**"); | ||||
|              | ||||
|             return title.ToString(); | ||||
|         } | ||||
|  | ||||
|         private SortFilterOptions GetOptions(Context ctx, PKSystem target) | ||||
|         { | ||||
|             var opts = SortFilterOptions.FromFlags(ctx); | ||||
|             opts.Filter = ctx.RemainderOrNull(); | ||||
|             // If we're *explicitly* trying to access non-public members of another system, error | ||||
|             if (opts.PrivacyFilter != PrivacyFilter.PublicOnly && ctx.LookupContextFor(target) != LookupContext.ByOwner) | ||||
|                 throw new PKError("You cannot look up private members of another system."); | ||||
|             return opts; | ||||
|         } | ||||
|  | ||||
|         private IListRenderer GetRendererFor(Context ctx, bool isLongList, SortFilterOptions opts) | ||||
|         { | ||||
|             if (isLongList) | ||||
|                 return new LongRenderer(LongRenderer.MemberFields.FromFlags(ctx, opts)); | ||||
|             return new ShortRenderer(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,16 +0,0 @@ | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| using DSharpPlus.Entities; | ||||
|  | ||||
| using NodaTime; | ||||
|  | ||||
| using PluralKit.Core; | ||||
|  | ||||
| namespace PluralKit.Bot | ||||
| { | ||||
|     public interface IListRenderer | ||||
|     { | ||||
|         int MembersPerPage { get; } | ||||
|         void RenderPage(DiscordEmbedBuilder eb, DateTimeZone zone, IEnumerable<ListedMember> members, LookupContext ctx); | ||||
|     } | ||||
| } | ||||
| @@ -1,76 +0,0 @@ | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| using DSharpPlus.Entities; | ||||
|  | ||||
| using Humanizer; | ||||
|  | ||||
| using NodaTime; | ||||
|  | ||||
| using PluralKit.Core; | ||||
|  | ||||
| namespace PluralKit.Bot | ||||
| { | ||||
|     public class LongRenderer: IListRenderer | ||||
|     { | ||||
|         public int MembersPerPage => 5; | ||||
|  | ||||
|         private readonly MemberFields _fields; | ||||
|         public LongRenderer(MemberFields fields) | ||||
|         { | ||||
|             _fields = fields; | ||||
|         } | ||||
|  | ||||
|         public void RenderPage(DiscordEmbedBuilder eb, DateTimeZone zone, IEnumerable<ListedMember> members, LookupContext ctx) | ||||
|         { | ||||
|             foreach (var m in members) | ||||
|             { | ||||
|                 var profile = $"**ID**: {m.Hid}"; | ||||
|                 if (_fields.ShowDisplayName && m.DisplayName != null && m.NamePrivacy.CanAccess(ctx)) profile += $"\n**Display name**: {m.DisplayName}"; | ||||
|                 if (_fields.ShowPronouns && m.PronounsFor(ctx) is {} pronouns) profile += $"\n**Pronouns**: {pronouns}"; | ||||
|                 if (_fields.ShowBirthday && m.BirthdayFor(ctx) != null) profile += $"\n**Birthdate**: {m.BirthdayString}"; | ||||
|                 if (_fields.ShowProxyTags && m.ProxyTags.Count > 0) profile += $"\n**Proxy tags:** {m.ProxyTagsString()}"; | ||||
|                 if (_fields.ShowMessageCount && m.MessageCountFor(ctx) is {} count && count > 0) profile += $"\n**Message count:** {count}"; | ||||
|                 if (_fields.ShowLastMessage && m.MetadataPrivacy.TryGet(ctx, m.LastMessage, out var lastMsg)) profile += $"\n**Last message:** {DiscordUtils.SnowflakeToInstant(lastMsg.Value).FormatZoned(zone)}"; | ||||
|                 if (_fields.ShowLastSwitch && m.MetadataPrivacy.TryGet(ctx, m.LastSwitchTime, out var lastSw)) profile += $"\n**Last switched in:** {lastSw.Value.FormatZoned(zone)}"; | ||||
|                 if (_fields.ShowDescription && m.DescriptionFor(ctx) is {} desc) profile += $"\n\n{desc}"; | ||||
|                 if (_fields.ShowPrivacy && m.MemberVisibility == PrivacyLevel.Private) profile += "\n*(this member is hidden)*"; | ||||
|  | ||||
|                 eb.AddField(m.NameFor(ctx), profile.Truncate(1024)); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         public class MemberFields | ||||
|         { | ||||
|             public bool ShowDisplayName = true; | ||||
|             public bool ShowCreated = false; | ||||
|             public bool ShowPronouns = true; | ||||
|             public bool ShowBirthday = true; | ||||
|             public bool ShowProxyTags = true; | ||||
|             public bool ShowDescription = true; | ||||
|             public bool ShowPrivacy = true; | ||||
|              | ||||
|             public bool ShowMessageCount = false; | ||||
|             public bool ShowLastSwitch = false; | ||||
|             public bool ShowLastMessage = false; | ||||
|  | ||||
|             public static MemberFields FromFlags(Context ctx, SortFilterOptions opts) | ||||
|             { | ||||
|                 var def = new MemberFields | ||||
|                 { | ||||
|                     // Add some defaults depending on sort order | ||||
|                     ShowLastMessage = opts.SortProperty == SortProperty.LastMessage, | ||||
|                     ShowLastSwitch = opts.SortProperty == SortProperty.LastSwitch, | ||||
|                     ShowMessageCount = opts.SortProperty == SortProperty.MessageCount | ||||
|                 }; | ||||
|                  | ||||
|                 if (ctx.MatchFlag("with-last-switch", "with-last-fronted", "with-last-front", "wls", "wlf")) | ||||
|                     def.ShowLastSwitch = true; | ||||
|                 if (ctx.MatchFlag("with-message-count", "wmc")) | ||||
|                     def.ShowMessageCount = true; | ||||
|                 if (ctx.MatchFlag("with-last-message", "with-last-proxy", "wlm", "wlp")) | ||||
|                     def.ShowLastMessage = true; | ||||
|                 return def; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,59 +0,0 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Text; | ||||
|  | ||||
| using DSharpPlus.Entities; | ||||
|  | ||||
| using NodaTime; | ||||
|  | ||||
| using PluralKit.Core; | ||||
|  | ||||
| namespace PluralKit.Bot | ||||
| { | ||||
|     public class ShortRenderer: IListRenderer | ||||
|     { | ||||
|         public int MembersPerPage => 25; | ||||
|          | ||||
|         public void RenderPage(DiscordEmbedBuilder eb, DateTimeZone timezone, IEnumerable<ListedMember> members, LookupContext ctx) | ||||
|         { | ||||
|             string RenderLine(ListedMember m) | ||||
|             { | ||||
|                 if (m.HasProxyTags) | ||||
|                 { | ||||
|                     var proxyTagsString = m.ProxyTagsString(); | ||||
|                     if (proxyTagsString.Length > 100) // arbitrary threshold for now, tweak? | ||||
|                         proxyTagsString = "tags too long, see member card"; | ||||
|                     return $"[`{m.Hid}`] **{m.NameFor(ctx)}** *(*{proxyTagsString}*)*"; | ||||
|                 } | ||||
|  | ||||
|                 return $"[`{m.Hid}`] **{m.NameFor(ctx)}**"; | ||||
|             } | ||||
|  | ||||
|             var buf = new StringBuilder(); | ||||
|             var chunks = new List<string>(); | ||||
|              | ||||
|             // Split the list into properly-sized chunks | ||||
|             foreach (var m in members) | ||||
|             { | ||||
|                 var line = RenderLine(m); | ||||
|                  | ||||
|                 // First chunk goes in description (2048 chars), rest go in embed values (1000 chars) | ||||
|                 var lengthLimit = chunks.Count == 0 ? 2048 : 1000; | ||||
|                 if (buf.Length + line.Length + 1 > lengthLimit) | ||||
|                 { | ||||
|                     chunks.Add(buf.ToString()); | ||||
|                     buf.Clear(); | ||||
|                 } | ||||
|  | ||||
|                 buf.Append(RenderLine(m)); | ||||
|                 buf.Append("\n"); | ||||
|             } | ||||
|             chunks.Add(buf.ToString()); | ||||
|  | ||||
|             // Put the first chunk in the description, rest in blank-name embed fields | ||||
|             eb.Description = chunks[0]; | ||||
|             for (var i = 1; i < chunks.Count; i++) | ||||
|                 // Field name is Unicode zero-width space | ||||
|                 eb.AddField("\u200B", chunks[i]); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,151 +0,0 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| using NodaTime; | ||||
|  | ||||
| using PluralKit.Core; | ||||
|  | ||||
| namespace PluralKit.Bot | ||||
| { | ||||
|     public class SortFilterOptions | ||||
|     { | ||||
|         public SortProperty SortProperty = SortProperty.Name; | ||||
|         public bool Reverse = false; | ||||
|         public PrivacyFilter PrivacyFilter = PrivacyFilter.PublicOnly; | ||||
|         public string Filter = null; | ||||
|         public bool SearchInDescription = false; | ||||
|  | ||||
|         public string CreateFilterString() | ||||
|         { | ||||
|             var str = new StringBuilder(); | ||||
|             str.Append("Sorting by "); | ||||
|             str.Append(SortProperty switch | ||||
|             { | ||||
|                 SortProperty.Name => "member name", | ||||
|                 SortProperty.Hid => "member ID", | ||||
|                 SortProperty.DisplayName => "display name", | ||||
|                 SortProperty.CreationDate => "creation date", | ||||
|                 SortProperty.LastMessage => "last message", | ||||
|                 SortProperty.LastSwitch => "last switch", | ||||
|                 SortProperty.MessageCount => "message count", | ||||
|                 SortProperty.Birthdate => "birthday", | ||||
|                 _ => new ArgumentOutOfRangeException($"Couldn't find readable string for sort property {SortProperty}") | ||||
|             }); | ||||
|              | ||||
|             if (Filter != null) | ||||
|             { | ||||
|                 str.Append($", searching for \"{Filter}\""); | ||||
|                 if (SearchInDescription) str.Append(" (including description)"); | ||||
|             } | ||||
|  | ||||
|             str.Append(PrivacyFilter switch | ||||
|             { | ||||
|                 PrivacyFilter.All => ", showing all members", | ||||
|                 PrivacyFilter.PrivateOnly => ", showing only private members", | ||||
|                 PrivacyFilter.PublicOnly => "", // (default, no extra line needed) | ||||
|                 _ => new ArgumentOutOfRangeException($"Couldn't find readable string for privacy filter {PrivacyFilter}") | ||||
|             }); | ||||
|              | ||||
|             return str.ToString(); | ||||
|         } | ||||
|          | ||||
|         public async Task<IEnumerable<ListedMember>> Execute(IPKConnection conn, PKSystem system, LookupContext ctx) | ||||
|         { | ||||
|             var filtered = await QueryWithFilter(conn, system, ctx); | ||||
|             return Sort(filtered, ctx); | ||||
|         } | ||||
|  | ||||
|         private Task<IEnumerable<ListedMember>> QueryWithFilter(IPKConnection conn, PKSystem system, LookupContext ctx) => | ||||
|             conn.QueryMemberList(system.Id, ctx, PrivacyFilter switch | ||||
|             { | ||||
|                 PrivacyFilter.PrivateOnly => PrivacyLevel.Private, | ||||
|                 PrivacyFilter.PublicOnly => PrivacyLevel.Public, | ||||
|                 PrivacyFilter.All => null, | ||||
|                 _ => throw new ArgumentOutOfRangeException($"Unknown privacy filter {PrivacyFilter}") | ||||
|             }, Filter, SearchInDescription); | ||||
|  | ||||
|         private IEnumerable<ListedMember> Sort(IEnumerable<ListedMember> input, LookupContext ctx) | ||||
|         { | ||||
|             IComparer<T> ReverseMaybe<T>(IComparer<T> c) => | ||||
|                 Reverse ? Comparer<T>.Create((a, b) => c.Compare(b, a)) : c; | ||||
|              | ||||
|             var culture = StringComparer.InvariantCultureIgnoreCase; | ||||
|             return (SortProperty switch | ||||
|             { | ||||
|                 // As for the OrderByDescending HasValue calls: https://www.jerriepelser.com/blog/orderby-with-null-values/ | ||||
|                 // We want nulls last no matter what, even if orders are reversed | ||||
|                 SortProperty.Hid => input.OrderBy(m => m.Hid, ReverseMaybe(culture)), | ||||
|                 SortProperty.Name => input.OrderBy(m => m.NameFor(ctx), ReverseMaybe(culture)), | ||||
|                 SortProperty.CreationDate => input.OrderBy(m => m.Created, ReverseMaybe(Comparer<Instant>.Default)), | ||||
|                 SortProperty.MessageCount => input.OrderByDescending(m => m.MessageCount, ReverseMaybe(Comparer<int>.Default)), | ||||
|                 SortProperty.DisplayName => input | ||||
|                     .OrderByDescending(m => m.DisplayName != null) | ||||
|                     .ThenBy(m => m.DisplayName, ReverseMaybe(culture)), | ||||
|                 SortProperty.Birthdate => input | ||||
|                     .OrderByDescending(m => m.AnnualBirthday.HasValue) | ||||
|                     .ThenBy(m => m.AnnualBirthday, ReverseMaybe(Comparer<AnnualDate?>.Default)), | ||||
|                 SortProperty.LastMessage => input | ||||
|                     .OrderByDescending(m => m.LastMessage.HasValue) | ||||
|                     .ThenByDescending(m => m.LastMessage, ReverseMaybe(Comparer<ulong?>.Default)), | ||||
|                 SortProperty.LastSwitch => input | ||||
|                     .OrderByDescending(m => m.LastSwitchTime.HasValue) | ||||
|                     .ThenByDescending(m => m.LastSwitchTime, ReverseMaybe(Comparer<Instant?>.Default)), | ||||
|                 _ => throw new ArgumentOutOfRangeException($"Unknown sort property {SortProperty}") | ||||
|             }) | ||||
|                 // Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values) | ||||
|                 .ThenBy(m => m.NameFor(ctx), culture); | ||||
|         } | ||||
|  | ||||
|         public static SortFilterOptions FromFlags(Context ctx) | ||||
|         { | ||||
|             var p = new SortFilterOptions(); | ||||
|              | ||||
|             // Sort property (default is by name, but adding a flag anyway, 'cause why not) | ||||
|             if (ctx.MatchFlag("by-name", "bn")) p.SortProperty = SortProperty.Name; | ||||
|             if (ctx.MatchFlag("by-display-name", "bdn")) p.SortProperty = SortProperty.DisplayName; | ||||
|             if (ctx.MatchFlag("by-id", "bid")) p.SortProperty = SortProperty.Hid; | ||||
|             if (ctx.MatchFlag("by-message-count", "bmc")) p.SortProperty = SortProperty.MessageCount; | ||||
|             if (ctx.MatchFlag("by-created", "bc")) p.SortProperty = SortProperty.CreationDate; | ||||
|             if (ctx.MatchFlag("by-last-fronted", "by-last-front", "by-last-switch", "blf", "bls")) p.SortProperty = SortProperty.LastSwitch; | ||||
|             if (ctx.MatchFlag("by-last-message", "blm", "blp")) p.SortProperty = SortProperty.LastMessage; | ||||
|             if (ctx.MatchFlag("by-birthday", "by-birthdate", "bbd")) p.SortProperty = SortProperty.Birthdate; | ||||
|              | ||||
|             // Sort reverse | ||||
|             if (ctx.MatchFlag("r", "rev", "reverse")) | ||||
|                 p.Reverse = true; | ||||
|  | ||||
|             // Include description in filter? | ||||
|             if (ctx.MatchFlag("search-description", "filter-description", "in-description", "sd", "description", "desc")) | ||||
|                 p.SearchInDescription = true; | ||||
|              | ||||
|             // Privacy filter (default is public only) | ||||
|             if (ctx.MatchFlag("a", "all")) p.PrivacyFilter = PrivacyFilter.All;  | ||||
|             if (ctx.MatchFlag("private-only")) p.PrivacyFilter = PrivacyFilter.PrivateOnly; | ||||
|             if (ctx.MatchFlag("public-only")) p.PrivacyFilter = PrivacyFilter.PublicOnly; | ||||
|              | ||||
|             return p; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public enum SortProperty | ||||
|     { | ||||
|         Name, | ||||
|         DisplayName, | ||||
|         Hid, | ||||
|         MessageCount, | ||||
|         CreationDate, | ||||
|         LastSwitch, | ||||
|         LastMessage, | ||||
|         Birthdate | ||||
|     } | ||||
|  | ||||
|     public enum PrivacyFilter | ||||
|     { | ||||
|         All, | ||||
|         PublicOnly, | ||||
|         PrivateOnly | ||||
|     } | ||||
| } | ||||
| @@ -13,6 +13,8 @@ using DSharpPlus.Exceptions; | ||||
|  | ||||
| using NodaTime; | ||||
|  | ||||
| using PluralKit.Core; | ||||
|  | ||||
| namespace PluralKit.Bot | ||||
| { | ||||
|     public static class DiscordUtils | ||||
| @@ -255,5 +257,25 @@ namespace PluralKit.Bot | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public static DiscordEmbedBuilder WithSimpleLineContent(this DiscordEmbedBuilder eb, IEnumerable<string> lines) | ||||
|         { | ||||
|             static int CharacterLimit(int pageNumber) => | ||||
|                 // First chunk goes in description (2048 chars), rest go in embed values (1000 chars) | ||||
|                 pageNumber == 0 ? 2048 : 1000; | ||||
|  | ||||
|             var linesWithEnding = lines.Select(l => $"{l}\n"); | ||||
|             var pages = StringUtils.JoinPages(linesWithEnding, CharacterLimit); | ||||
|  | ||||
|             // Add the first page to the embed description | ||||
|             if (pages.Count > 0) | ||||
|                 eb.WithDescription(pages[0]); | ||||
|              | ||||
|             // Add the rest to blank-named (\u200B) fields | ||||
|             for (var i = 1; i < pages.Count; i++) | ||||
|                 eb.AddField("\u200B", pages[i]); | ||||
|  | ||||
|             return eb; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -11,31 +11,39 @@ namespace PluralKit.Core | ||||
|     { | ||||
|         public static Task<IEnumerable<SystemFronter>> QueryCurrentFronters(this IPKConnection conn, SystemId system) => | ||||
|             conn.QueryAsync<SystemFronter>("select * from system_fronters where system = @system", new {system}); | ||||
|  | ||||
|         public static Task<IEnumerable<ListedMember>> QueryMemberList(this IPKConnection conn, SystemId system, LookupContext ctx, PrivacyLevel? privacyFilter = null, string? filter = null, bool includeDescriptionInNameFilter = false) | ||||
|          | ||||
|         public static Task<IEnumerable<ListedMember>> QueryMemberList(this IPKConnection conn, SystemId system, MemberListQueryOptions opts) | ||||
|         { | ||||
|             StringBuilder query = new StringBuilder("select * from member_list where system = @system"); | ||||
|  | ||||
|             if (privacyFilter != null) | ||||
|                 query.Append($" and member_visibility = {(int) privacyFilter}"); | ||||
|             if (opts.PrivacyFilter != null) | ||||
|                 query.Append($" and member_visibility = {(int) opts.PrivacyFilter}"); | ||||
|  | ||||
|             if (filter != null) | ||||
|             if (opts.Search != null) | ||||
|             { | ||||
|                 static string Filter(string column) => $"(position(lower(@filter) in lower(coalesce({column}, ''))) > 0)";  | ||||
|  | ||||
|                 query.Append($" and ({Filter("name")} or {Filter("display_name")}"); | ||||
|                 if (includeDescriptionInNameFilter) | ||||
|                 if (opts.SearchDescription) | ||||
|                 { | ||||
|                     // We need to account for the possibility of description privacy when searching | ||||
|                     // If we're looking up from the outside, only search "public_description" (defined in the view; null if desc is private) | ||||
|                     // If we're the owner, just search the full description | ||||
|                     var descriptionColumn = ctx == LookupContext.ByOwner ? "description" : "public_description"; | ||||
|                     var descriptionColumn = opts.Context == LookupContext.ByOwner ? "description" : "public_description"; | ||||
|                     query.Append($"or {Filter(descriptionColumn)}"); | ||||
|                 } | ||||
|                 query.Append(")"); | ||||
|             } | ||||
|              | ||||
|             return conn.QueryAsync<ListedMember>(query.ToString(), new {system, filter}); | ||||
|             return conn.QueryAsync<ListedMember>(query.ToString(), new {system, filter = opts.Search}); | ||||
|         } | ||||
|          | ||||
|         public struct MemberListQueryOptions | ||||
|         { | ||||
|             public PrivacyLevel? PrivacyFilter; | ||||
|             public string? Search; | ||||
|             public bool SearchDescription; | ||||
|             public LookupContext Context; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,7 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Text.RegularExpressions; | ||||
|  | ||||
| namespace PluralKit.Core | ||||
| @@ -56,5 +58,36 @@ namespace PluralKit.Core | ||||
|             // so we remove 'em all :) | ||||
|             return Regex.Replace(input, " *\n", "\n"); | ||||
|         } | ||||
|  | ||||
|         public static IReadOnlyList<string> JoinPages(IEnumerable<string> input, int characterLimit) => | ||||
|             JoinPages(input, _ => characterLimit); | ||||
|  | ||||
|         public static IReadOnlyList<string> JoinPages(IEnumerable<string> input, Func<int, int> characterLimitByPage) | ||||
|         { | ||||
|             var output = new List<string>(); | ||||
|  | ||||
|             var buf = new StringBuilder(); | ||||
|             foreach (var s in input) | ||||
|             { | ||||
|                 var limit = characterLimitByPage.Invoke(output.Count); | ||||
|                  | ||||
|                 // Would adding this string put us over the limit? | ||||
|                 // (note: don't roll over if the buffer's already empty; this means an individual section is above the character limit. todo: truncate, then?) | ||||
|                 if (buf.Length > 0 && buf.Length + s.Length > limit) | ||||
|                 { | ||||
|                     // If so, "roll over" (before adding the string to the buffer) | ||||
|                     output.Add(buf.ToString()); | ||||
|                     buf.Clear(); | ||||
|                 } | ||||
|  | ||||
|                 buf.Append(s); | ||||
|             } | ||||
|              | ||||
|             // We most likely have something left over, so add that in too | ||||
|             if (buf.Length > 0) | ||||
|                 output.Add(buf.ToString()); | ||||
|  | ||||
|             return output; | ||||
|         }  | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user