diff --git a/PluralKit.Bot/Commands/Lists/ContextListExt.cs b/PluralKit.Bot/Commands/Lists/ContextListExt.cs new file mode 100644 index 00000000..e687eeb8 --- /dev/null +++ b/PluralKit.Bot/Commands/Lists/ContextListExt.cs @@ -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 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 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 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)); + } + } + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Lists/MemberListOptions.cs b/PluralKit.Bot/Commands/Lists/MemberListOptions.cs new file mode 100644 index 00000000..9e00b226 --- /dev/null +++ b/PluralKit.Bot/Commands/Lists/MemberListOptions.cs @@ -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 SortByMemberListOptions(this IEnumerable input, MemberListOptions opts, LookupContext ctx) + { + IComparer ReverseMaybe(IComparer c) => + opts.Reverse ? Comparer.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.Default)), + SortProperty.MessageCount => input.OrderByDescending(m => m.MessageCount, ReverseMaybe(Comparer.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.Default)), + SortProperty.LastMessage => input + .OrderByDescending(m => m.LastMessage.HasValue) + .ThenByDescending(m => m.LastMessage, ReverseMaybe(Comparer.Default)), + SortProperty.LastSwitch => input + .OrderByDescending(m => m.LastSwitchTime.HasValue) + .ThenByDescending(m => m.LastSwitchTime, ReverseMaybe(Comparer.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 + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemList.cs b/PluralKit.Bot/Commands/SystemList.cs index ffa23c2d..7d7db63d 100644 --- a/PluralKit.Bot/Commands/SystemList.cs +++ b/PluralKit.Bot/Commands/SystemList.cs @@ -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(); - } } } \ No newline at end of file diff --git a/PluralKit.Bot/Lists/IListRenderer.cs b/PluralKit.Bot/Lists/IListRenderer.cs deleted file mode 100644 index 24bf545d..00000000 --- a/PluralKit.Bot/Lists/IListRenderer.cs +++ /dev/null @@ -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 members, LookupContext ctx); - } -} \ No newline at end of file diff --git a/PluralKit.Bot/Lists/LongRenderer.cs b/PluralKit.Bot/Lists/LongRenderer.cs deleted file mode 100644 index 7431999e..00000000 --- a/PluralKit.Bot/Lists/LongRenderer.cs +++ /dev/null @@ -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 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; - } - } - } -} \ No newline at end of file diff --git a/PluralKit.Bot/Lists/ShortRenderer.cs b/PluralKit.Bot/Lists/ShortRenderer.cs deleted file mode 100644 index 5b668218..00000000 --- a/PluralKit.Bot/Lists/ShortRenderer.cs +++ /dev/null @@ -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 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(); - - // 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]); - } - } -} \ No newline at end of file diff --git a/PluralKit.Bot/Lists/SortFilterOptions.cs b/PluralKit.Bot/Lists/SortFilterOptions.cs deleted file mode 100644 index b23711a9..00000000 --- a/PluralKit.Bot/Lists/SortFilterOptions.cs +++ /dev/null @@ -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> Execute(IPKConnection conn, PKSystem system, LookupContext ctx) - { - var filtered = await QueryWithFilter(conn, system, ctx); - return Sort(filtered, ctx); - } - - private Task> 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 Sort(IEnumerable input, LookupContext ctx) - { - IComparer ReverseMaybe(IComparer c) => - Reverse ? Comparer.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.Default)), - SortProperty.MessageCount => input.OrderByDescending(m => m.MessageCount, ReverseMaybe(Comparer.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.Default)), - SortProperty.LastMessage => input - .OrderByDescending(m => m.LastMessage.HasValue) - .ThenByDescending(m => m.LastMessage, ReverseMaybe(Comparer.Default)), - SortProperty.LastSwitch => input - .OrderByDescending(m => m.LastSwitchTime.HasValue) - .ThenByDescending(m => m.LastSwitchTime, ReverseMaybe(Comparer.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 - } -} \ No newline at end of file diff --git a/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index 5c6e400b..84c02bdb 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -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 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; + } } } \ No newline at end of file diff --git a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs index 97e59efa..5a0ef688 100644 --- a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs +++ b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs @@ -11,31 +11,39 @@ namespace PluralKit.Core { public static Task> QueryCurrentFronters(this IPKConnection conn, SystemId system) => conn.QueryAsync("select * from system_fronters where system = @system", new {system}); - - public static Task> QueryMemberList(this IPKConnection conn, SystemId system, LookupContext ctx, PrivacyLevel? privacyFilter = null, string? filter = null, bool includeDescriptionInNameFilter = false) + + public static Task> 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(query.ToString(), new {system, filter}); + return conn.QueryAsync(query.ToString(), new {system, filter = opts.Search}); + } + + public struct MemberListQueryOptions + { + public PrivacyLevel? PrivacyFilter; + public string? Search; + public bool SearchDescription; + public LookupContext Context; } } } \ No newline at end of file diff --git a/PluralKit.Core/Utils/StringUtils.cs b/PluralKit.Core/Utils/StringUtils.cs index d8f1fa82..a98c5a36 100644 --- a/PluralKit.Core/Utils/StringUtils.cs +++ b/PluralKit.Core/Utils/StringUtils.cs @@ -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 JoinPages(IEnumerable input, int characterLimit) => + JoinPages(input, _ => characterLimit); + + public static IReadOnlyList JoinPages(IEnumerable input, Func characterLimitByPage) + { + var output = new List(); + + 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; + } } } \ No newline at end of file