Refactor and simplify member list code
This commit is contained in:
parent
299f6b2edf
commit
467b95b1b1
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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user