From 7c85dc360b1c3963936c48150940c8bb9b619ca4 Mon Sep 17 00:00:00 2001 From: Ske Date: Thu, 4 Jun 2020 13:21:47 +0200 Subject: [PATCH 1/2] Barebones, untested sort/filtering --- PluralKit.Bot/Commands/CommandTree.cs | 6 +- PluralKit.Bot/Commands/SystemList.cs | 146 +++++++-------------- PluralKit.Bot/Lists/IListRenderer.cs | 12 ++ PluralKit.Bot/Lists/LongRenderer.cs | 62 +++++++++ PluralKit.Bot/Lists/PKListMember.cs | 9 ++ PluralKit.Bot/Lists/ShortRenderer.cs | 30 +++++ PluralKit.Bot/Lists/SortFilterOptions.cs | 154 +++++++++++++++++++++++ 7 files changed, 315 insertions(+), 104 deletions(-) create mode 100644 PluralKit.Bot/Lists/IListRenderer.cs create mode 100644 PluralKit.Bot/Lists/LongRenderer.cs create mode 100644 PluralKit.Bot/Lists/PKListMember.cs create mode 100644 PluralKit.Bot/Lists/ShortRenderer.cs create mode 100644 PluralKit.Bot/Lists/SortFilterOptions.cs diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index 4aac3fd2..03d13e44 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -100,7 +100,7 @@ namespace PluralKit.Bot if (ctx.Match("list", "l", "members")) return ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); if (ctx.Match("f", "find", "search", "query", "fd")) - return ctx.Execute(SystemFind, m => m.MemberFind(ctx, ctx.System)); + return ctx.Execute(SystemFind, m => m.MemberList(ctx, ctx.System)); if (ctx.Match("link")) return ctx.Execute(Link, m => m.LinkSystem(ctx)); if (ctx.Match("unlink")) @@ -188,7 +188,7 @@ namespace PluralKit.Bot else if (ctx.Match("list", "l", "members")) await ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); else if (ctx.Match("find", "search", "query", "fd", "s")) - await ctx.Execute(SystemFind, m => m.MemberFind(ctx, ctx.System)); + await ctx.Execute(SystemFind, m => m.MemberList(ctx, ctx.System)); else if (ctx.Match("f", "front", "fronter", "fronters")) { if (ctx.Match("h", "history")) @@ -225,7 +225,7 @@ namespace PluralKit.Bot else if (ctx.Match("list", "l", "members")) await ctx.Execute(SystemList, m => m.MemberList(ctx, target)); else if (ctx.Match("find", "search", "query", "fd", "s")) - await ctx.Execute(SystemFind, m => m.MemberFind(ctx, target)); + await ctx.Execute(SystemFind, m => m.MemberList(ctx, target)); else if (ctx.Match("f", "front", "fronter", "fronters")) { if (ctx.Match("h", "history")) diff --git a/PluralKit.Bot/Commands/SystemList.cs b/PluralKit.Bot/Commands/SystemList.cs index 9f7ae212..9c2176f8 100644 --- a/PluralKit.Bot/Commands/SystemList.cs +++ b/PluralKit.Bot/Commands/SystemList.cs @@ -1,11 +1,9 @@ -using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading.Tasks; -using DSharpPlus.Entities; - -using Humanizer; +using Dapper; using PluralKit.Core; @@ -13,124 +11,70 @@ namespace PluralKit.Bot { public class SystemList { - private IDataStore _data; - - public SystemList(IDataStore data) + private readonly DbConnectionFactory _db; + + public SystemList(DbConnectionFactory db) { - _data = data; + _db = db; } - private async Task RenderMemberList(Context ctx, PKSystem system, bool canShowPrivate, int membersPerPage, string embedTitle, Func filter, - Func, Task> - renderer) + public async Task MemberList(Context ctx, PKSystem target) { - var authCtx = ctx.LookupContextFor(system); - var shouldShowPrivate = authCtx == LookupContext.ByOwner && canShowPrivate; - - var membersToShow = await _data.GetSystemMembers(system) - .Where(filter) - .OrderBy(m => m.Name, StringComparer.InvariantCultureIgnoreCase) - .ToListAsync(); - - var membersToShowWithPrivacy = membersToShow - .Where(m => m.MemberPrivacy == PrivacyLevel.Public || shouldShowPrivate) - .ToList(); - - var anyMembersHidden = !shouldShowPrivate && membersToShowWithPrivacy.Count != membersToShow.Count; + if (target == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(target, target.MemberListPrivacy); + // GetRendererFor must be called before GetOptions as it consumes a potential positional full argument that'd otherwise land in the filter + var renderer = GetRendererFor(ctx); + var opts = GetOptions(ctx, target); + var members = await GetMemberList(target, opts); await ctx.Paginate( - membersToShowWithPrivacy.ToAsyncEnumerable(), - membersToShowWithPrivacy.Count, - membersPerPage, - embedTitle, + members.ToAsyncEnumerable(), + members.Count, + renderer.MembersPerPage, + GetEmbedTitle(target, opts), (eb, ms) => { - var footer = $"{membersToShowWithPrivacy.Count} total."; - if (anyMembersHidden && authCtx == LookupContext.ByOwner) - footer += " Private members have been hidden. Add \"all\" to the command to include them."; - eb.WithFooter(footer); - - return renderer(eb, ms); + eb.WithFooter($"{members.Count} total."); + renderer.RenderPage(eb, ms); + return Task.CompletedTask; }); } - private Task ShortRenderer(DiscordEmbedBuilder eb, IEnumerable members) + private async Task> GetMemberList(PKSystem target, SortFilterOptions opts) { - eb.Description = string.Join("\n", members.Select((m) => - { - if (m.HasProxyTags) - { - var proxyTagsString = m.ProxyTagsString().SanitizeMentions(); - if (proxyTagsString.Length > 100) // arbitrary threshold for now, tweak? - proxyTagsString = "tags too long, see member card"; - - return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}** *({proxyTagsString})*"; - } - - return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}**"; - })); - - return Task.CompletedTask; + using var conn = await _db.Obtain(); + var args = new {System = target.Id, opts.Filter}; + return (await conn.QueryAsync(opts.BuildQuery(), args)).ToList(); } - private Task LongRenderer(DiscordEmbedBuilder eb, IEnumerable members) + private string GetEmbedTitle(PKSystem target, SortFilterOptions opts) { - foreach (var m in members) - { - var profile = $"**ID**: {m.Hid}"; - if (m.DisplayName != null) profile += $"\n**Display name**: {m.DisplayName}"; - if (m.Pronouns != null) profile += $"\n**Pronouns**: {m.Pronouns}"; - if (m.Birthday != null) profile += $"\n**Birthdate**: {m.BirthdayString}"; - if (m.ProxyTags.Count > 0) profile += $"\n**Proxy tags:** {m.ProxyTagsString()}"; - if (m.Description != null) profile += $"\n\n{m.Description}"; - if (m.MemberPrivacy == PrivacyLevel.Private) - profile += "\n*(this member is private)*"; - - eb.AddField(m.Name, profile.Truncate(1024)); - } - - return Task.CompletedTask; + var title = new StringBuilder("Members of "); + + if (target.Name != null) title.Append($"{target.Name.SanitizeMentions()} (`{target.Hid}`)"); + else title.Append($"`{target.Hid}`"); + + if (opts.Filter != null) title.Append($"matching **{opts.Filter.SanitizeMentions()}**"); + + return title.ToString(); } - public async Task MemberList(Context ctx, PKSystem system) + private SortFilterOptions GetOptions(Context ctx, PKSystem target) { - if (system == null) throw Errors.NoSystemError; - ctx.CheckSystemPrivacy(system, system.MemberListPrivacy); - - var embedTitle = system.Name != null - ? $"Members of {system.Name.SanitizeMentions()} (`{system.Hid}`)" - : $"Members of `{system.Hid}`"; - - var shouldShowLongList = ctx.Match("f", "full", "big", "details", "long"); - var canShowPrivate = ctx.Match("a", "all", "everyone", "private"); - if (shouldShowLongList) - await RenderMemberList(ctx, system, canShowPrivate, 5, embedTitle, _ => true, LongRenderer); - else - await RenderMemberList(ctx, system, canShowPrivate, 25, embedTitle, _ => true, ShortRenderer); + 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; } - public async Task MemberFind(Context ctx, PKSystem system) + private IListRenderer GetRendererFor(Context ctx) { - if (system == null) throw Errors.NoSystemError; - ctx.CheckSystemPrivacy(system, system.MemberListPrivacy); - - var shouldShowLongList = ctx.Match("full", "big", "details", "long") || ctx.MatchFlag("f", "full"); - var canShowPrivate = ctx.Match("all", "everyone", "private") || ctx.MatchFlag("a", "all"); - - var searchTerm = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must specify a search term."); - - var embedTitle = system.Name != null - ? $"Members of {system.Name.SanitizeMentions()} (`{system.Hid}`) matching **{searchTerm.SanitizeMentions()}**" - : $"Members of `{system.Hid}` matching **{searchTerm.SanitizeMentions()}**"; - - bool Filter(PKMember member) => - member.Name.Contains(searchTerm, StringComparison.InvariantCultureIgnoreCase) || - (member.DisplayName?.Contains(searchTerm, StringComparison.InvariantCultureIgnoreCase) ?? false); - - if (shouldShowLongList) - await RenderMemberList(ctx, system, canShowPrivate, 5, embedTitle, Filter, LongRenderer); - else - await RenderMemberList(ctx, system, canShowPrivate, 25, embedTitle, Filter, ShortRenderer); + var longList = ctx.Match("f", "full", "big", "details", "long") || ctx.MatchFlag("f", "full"); + if (longList) + return new LongRenderer(LongRenderer.MemberFields.FromFlags(ctx)); + return new ShortRenderer(); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Lists/IListRenderer.cs b/PluralKit.Bot/Lists/IListRenderer.cs new file mode 100644 index 00000000..4504944e --- /dev/null +++ b/PluralKit.Bot/Lists/IListRenderer.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +using DSharpPlus.Entities; + +namespace PluralKit.Bot +{ + public interface IListRenderer + { + int MembersPerPage { get; } + void RenderPage(DiscordEmbedBuilder eb, IEnumerable members); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Lists/LongRenderer.cs b/PluralKit.Bot/Lists/LongRenderer.cs new file mode 100644 index 00000000..622ca8d9 --- /dev/null +++ b/PluralKit.Bot/Lists/LongRenderer.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; + +using DSharpPlus.Entities; + +using Humanizer; + +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, IEnumerable members) + { + foreach (var m in members) + { + var profile = $"**ID**: {m.Hid}"; + if (_fields.ShowDisplayName && m.DisplayName != null) profile += $"\n**Display name**: {m.DisplayName}"; + if (_fields.ShowPronouns && m.Pronouns != null) profile += $"\n**Pronouns**: {m.Pronouns}"; + if (_fields.ShowBirthday && m.Birthday != null) profile += $"\n**Birthdate**: {m.BirthdayString}"; + if (_fields.ShowPronouns && m.ProxyTags.Count > 0) profile += $"\n**Proxy tags:** {m.ProxyTagsString()}"; + if (_fields.ShowMessageCount && m.MessageCount > 0) profile += $"\n**Message count:** {m.MessageCount}"; + if (_fields.ShowDescription && m.Description != null) profile += $"\n\n{m.Description}"; + if (_fields.ShowPrivacy && m.MemberPrivacy == PrivacyLevel.Private) + profile += "\n*(this member is private)*"; + + eb.AddField(m.Name, profile.Truncate(1024)); + } + } + + public class MemberFields + { + public bool ShowDisplayName = true; + public bool ShowCreated = true; + public bool ShowMessageCount = true; + public bool ShowPronouns = true; + public bool ShowBirthday = true; + public bool ShowProxyTags = true; + public bool ShowDescription = true; + public bool ShowPrivacy = true; + + public static MemberFields FromFlags(Context ctx) + { + // TODO + return new MemberFields + { + ShowMessageCount = false, + ShowCreated = false + }; + } + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Lists/PKListMember.cs b/PluralKit.Bot/Lists/PKListMember.cs new file mode 100644 index 00000000..6937b53e --- /dev/null +++ b/PluralKit.Bot/Lists/PKListMember.cs @@ -0,0 +1,9 @@ +using PluralKit.Core; + +namespace PluralKit.Bot +{ + public class PKListMember: PKMember + { + public int MessageCount { get; set; } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Lists/ShortRenderer.cs b/PluralKit.Bot/Lists/ShortRenderer.cs new file mode 100644 index 00000000..dd6522cb --- /dev/null +++ b/PluralKit.Bot/Lists/ShortRenderer.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using DSharpPlus.Entities; + +namespace PluralKit.Bot +{ + public class ShortRenderer: IListRenderer + { + public int MembersPerPage => 25; + + public void RenderPage(DiscordEmbedBuilder eb, IEnumerable members) + { + eb.Description = string.Join("\n", members.Select(m => + { + if (m.HasProxyTags) + { + var proxyTagsString = m.ProxyTagsString().SanitizeMentions(); + if (proxyTagsString.Length > 100) // arbitrary threshold for now, tweak? + proxyTagsString = "tags too long, see member card"; + + return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}** *({proxyTagsString})*"; + } + + return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}**"; + })); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Lists/SortFilterOptions.cs b/PluralKit.Bot/Lists/SortFilterOptions.cs new file mode 100644 index 00000000..7e1832a2 --- /dev/null +++ b/PluralKit.Bot/Lists/SortFilterOptions.cs @@ -0,0 +1,154 @@ +using System.Text; + +using PluralKit.Core; + +namespace PluralKit.Bot +{ + public class SortFilterOptions + { + public SortProperty SortProperty = SortProperty.Name; + public SortDirection Direction = SortDirection.Ascending; + public PrivacyFilter PrivacyFilter = PrivacyFilter.PublicOnly; + public string Filter = null; + public bool SearchInDescription = false; + + public string CreateFilterString() + { + // TODO + return "uwu"; + } + + public string BuildQuery() + { + // Select clause + StringBuilder query = new StringBuilder(); + query.Append(SortProperty switch + { + SortProperty.MessageCount => + "select members.*, count(messages.*) as message_count, max(messages.mid) as last_message from members", + SortProperty.LastMessage => + "select members.*, count(messages.*) as message_count, max(messages.mid) as last_message from members", + SortProperty.LastSwitch => "select members.*, max(switches.timestamp) as last_switch_time from members", + _ => "select members.* from members" + }); + + // Join clauses + query.Append(SortProperty switch + { + SortProperty.MessageCount => " left join messages on messages.member = members.id", + SortProperty.LastMessage => " left join messages on messages.member = members.id", + SortProperty.LastSwitch => + " left join switch_members on switch_members.member = members.id left join switches on switch_members.switch = switches.id", + _ => "" + }); + + // Where clauses + query.Append(" where members.system = @System"); + + // Privacy filter + query.Append(PrivacyFilter switch + { + PrivacyFilter.PrivateOnly => $" and members.member_privacy = {(int) PrivacyLevel.Private}", + PrivacyFilter.PublicOnly => $" and members.member_privacy = {(int) PrivacyLevel.Public}", + _ => "" + }); + + // String filter + if (Filter != null) + { + // Use position rather than ilike to not bother with escaping and such + query.Append(" and ("); + query.Append( + "position(lower(@Filter) in lower(members.name)) > 0 or position(lower(@Filter) in lower(coalesce(members.display_name, ''))) > 0"); + if (SearchInDescription) + query.Append(" or position(lower(@Filter) in lower(coalesce(members.description, ''))) > 0"); + query.Append(")"); + } + + // Group clause + query.Append(SortProperty switch + { + SortProperty.MessageCount => " group by members.id", + SortProperty.LastMessage => " group by members.id", + SortProperty.LastSwitch => " group by members.id", + _ => "" + }); + + // Order clause + query.Append(SortProperty switch + { + SortProperty.Hid => " order by members.hid", + SortProperty.CreationDate => " order by members.created", + SortProperty.Birthdate => + " order by extract(month from members.birthday), extract(day from members.birthday)", + SortProperty.MessageCount => " order by count(messages.mid)", + SortProperty.LastMessage => " order by max(messages.mid)", + SortProperty.LastSwitch => " order by max(switches.timestamp)", + _ => " order by members.name" + }); + + // Order direction + if (Direction == SortDirection.Descending) + query.Append(" desc"); + + return query.ToString(); + } + + public static SortFilterOptions FromFlags(Context ctx) + { + var p = new SortFilterOptions(); + + // Direction + if (ctx.MatchFlag("r", "rev", "reverse", "desc", "descending")) + p.Direction = SortDirection.Descending; + + // Sort + if (ctx.MatchFlag("by-id", "bi")) + p.SortProperty = SortProperty.Hid; + else if (ctx.MatchFlag("by-msgcount", "by-mc", "by-msgs", "bm")) + p.SortProperty = SortProperty.MessageCount; + else if (ctx.MatchFlag("by-date", "by-time", "by-created", "bc")) + p.SortProperty = SortProperty.CreationDate; + else if (ctx.MatchFlag("by-switch")) + p.SortProperty = SortProperty.LastSwitch; + else if (ctx.MatchFlag("by-last-message")) + p.SortProperty = SortProperty.LastMessage; + + // Description + if (ctx.MatchFlag("desc")) + p.SearchInDescription = true; + + // Privacy + if (ctx.MatchFlag("a", "all")) + p.PrivacyFilter = PrivacyFilter.All; + else if (ctx.MatchFlag("po", "private", "private-only", "only-private", "priv")) + p.PrivacyFilter = PrivacyFilter.PrivateOnly; + + return p; + } + } + + public enum SortProperty + { + Name, + Hid, + MessageCount, + CreationDate, + LastSwitch, + LastMessage, + Birthdate + } + + public enum SortDirection + { + Ascending, + Descending + } + + public enum PrivacyFilter + { + All, + PublicOnly, + PrivateOnly + } +} \ No newline at end of file From 1ac5f9518ea1c99b389ebd06937fc71cbc0565a9 Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 7 Jun 2020 01:30:19 +0200 Subject: [PATCH 2/2] Mostly finished, needs to be double-checked/documented --- PluralKit.Bot/Commands/SystemList.cs | 25 +++- PluralKit.Bot/Lists/IListRenderer.cs | 4 +- PluralKit.Bot/Lists/LongRenderer.cs | 31 +++-- PluralKit.Bot/Lists/PKListMember.cs | 6 +- PluralKit.Bot/Lists/ShortRenderer.cs | 10 +- PluralKit.Bot/Lists/SortFilterOptions.cs | 148 +++++++++++++---------- PluralKit.Bot/Modules.cs | 3 + 7 files changed, 143 insertions(+), 84 deletions(-) diff --git a/PluralKit.Bot/Commands/SystemList.cs b/PluralKit.Bot/Commands/SystemList.cs index 9c2176f8..4ce59c28 100644 --- a/PluralKit.Bot/Commands/SystemList.cs +++ b/PluralKit.Bot/Commands/SystemList.cs @@ -5,17 +5,25 @@ using System.Threading.Tasks; using Dapper; +using NodaTime; + using PluralKit.Core; +using Serilog; + namespace PluralKit.Bot { public class SystemList { + private readonly IClock _clock; private readonly DbConnectionFactory _db; + private readonly ILogger _logger; - public SystemList(DbConnectionFactory db) + public SystemList(DbConnectionFactory db, ILogger logger, IClock clock) { _db = db; + _logger = logger; + _clock = clock; } public async Task MemberList(Context ctx, PKSystem target) @@ -34,8 +42,8 @@ namespace PluralKit.Bot GetEmbedTitle(target, opts), (eb, ms) => { - eb.WithFooter($"{members.Count} total."); - renderer.RenderPage(eb, ms); + eb.WithFooter($"{opts.CreateFilterString()}. {members.Count} results."); + renderer.RenderPage(eb, ctx.System, ms); return Task.CompletedTask; }); } @@ -43,8 +51,15 @@ namespace PluralKit.Bot private async Task> GetMemberList(PKSystem target, SortFilterOptions opts) { using var conn = await _db.Obtain(); + var query = opts.BuildQuery(); var args = new {System = target.Id, opts.Filter}; - return (await conn.QueryAsync(opts.BuildQuery(), args)).ToList(); + + var timeBefore = _clock.GetCurrentInstant(); + var results = (await conn.QueryAsync(query, args)).ToList(); + var timeAfter = _clock.GetCurrentInstant(); + _logger.Debug("Executing sort/filter query `{Query}` with arguments {Args} returning {ResultCount} results in {QueryTime}", query, args, results.Count, timeAfter - timeBefore); + + return results; } private string GetEmbedTitle(PKSystem target, SortFilterOptions opts) @@ -54,7 +69,7 @@ namespace PluralKit.Bot if (target.Name != null) title.Append($"{target.Name.SanitizeMentions()} (`{target.Hid}`)"); else title.Append($"`{target.Hid}`"); - if (opts.Filter != null) title.Append($"matching **{opts.Filter.SanitizeMentions()}**"); + if (opts.Filter != null) title.Append($" matching **{opts.Filter.SanitizeMentions()}**"); return title.ToString(); } diff --git a/PluralKit.Bot/Lists/IListRenderer.cs b/PluralKit.Bot/Lists/IListRenderer.cs index 4504944e..54c70d44 100644 --- a/PluralKit.Bot/Lists/IListRenderer.cs +++ b/PluralKit.Bot/Lists/IListRenderer.cs @@ -2,11 +2,13 @@ using DSharpPlus.Entities; +using PluralKit.Core; + namespace PluralKit.Bot { public interface IListRenderer { int MembersPerPage { get; } - void RenderPage(DiscordEmbedBuilder eb, IEnumerable members); + void RenderPage(DiscordEmbedBuilder eb, PKSystem system, IEnumerable members); } } \ No newline at end of file diff --git a/PluralKit.Bot/Lists/LongRenderer.cs b/PluralKit.Bot/Lists/LongRenderer.cs index 622ca8d9..00be46a8 100644 --- a/PluralKit.Bot/Lists/LongRenderer.cs +++ b/PluralKit.Bot/Lists/LongRenderer.cs @@ -5,6 +5,8 @@ using DSharpPlus.Entities; using Humanizer; +using NodaTime; + using PluralKit.Core; namespace PluralKit.Bot @@ -19,7 +21,7 @@ namespace PluralKit.Bot _fields = fields; } - public void RenderPage(DiscordEmbedBuilder eb, IEnumerable members) + public void RenderPage(DiscordEmbedBuilder eb, PKSystem system, IEnumerable members) { foreach (var m in members) { @@ -29,6 +31,8 @@ namespace PluralKit.Bot if (_fields.ShowBirthday && m.Birthday != null) profile += $"\n**Birthdate**: {m.BirthdayString}"; if (_fields.ShowPronouns && m.ProxyTags.Count > 0) profile += $"\n**Proxy tags:** {m.ProxyTagsString()}"; if (_fields.ShowMessageCount && m.MessageCount > 0) profile += $"\n**Message count:** {m.MessageCount}"; + if (_fields.ShowLastMessage && m.LastMessage != null) profile += $"\n**Last message:** {FormatTimestamp(system, DiscordUtils.SnowflakeToInstant(m.LastMessage.Value))}"; + if (_fields.ShowLastSwitch && m.LastSwitchTime != null) profile += $"\n**Last switched in:** {FormatTimestamp(system, m.LastSwitchTime.Value)}"; if (_fields.ShowDescription && m.Description != null) profile += $"\n\n{m.Description}"; if (_fields.ShowPrivacy && m.MemberPrivacy == PrivacyLevel.Private) profile += "\n*(this member is private)*"; @@ -36,26 +40,33 @@ namespace PluralKit.Bot eb.AddField(m.Name, profile.Truncate(1024)); } } - + + private static string FormatTimestamp(PKSystem system, Instant timestamp) => DateTimeFormats.ZonedDateTimeFormat.Format(timestamp.InZone(system.Zone ?? DateTimeZone.Utc)); + public class MemberFields { public bool ShowDisplayName = true; - public bool ShowCreated = true; - public bool ShowMessageCount = 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) { - // TODO - return new MemberFields - { - ShowMessageCount = false, - ShowCreated = false - }; + var def = new MemberFields(); + 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; } } } diff --git a/PluralKit.Bot/Lists/PKListMember.cs b/PluralKit.Bot/Lists/PKListMember.cs index 6937b53e..2e91efc9 100644 --- a/PluralKit.Bot/Lists/PKListMember.cs +++ b/PluralKit.Bot/Lists/PKListMember.cs @@ -1,9 +1,13 @@ -using PluralKit.Core; +using NodaTime; + +using PluralKit.Core; namespace PluralKit.Bot { public class PKListMember: PKMember { public int MessageCount { get; set; } + public ulong? LastMessage { get; set; } + public Instant? LastSwitchTime { get; set; } } } \ No newline at end of file diff --git a/PluralKit.Bot/Lists/ShortRenderer.cs b/PluralKit.Bot/Lists/ShortRenderer.cs index dd6522cb..fa056927 100644 --- a/PluralKit.Bot/Lists/ShortRenderer.cs +++ b/PluralKit.Bot/Lists/ShortRenderer.cs @@ -4,15 +4,17 @@ using System.Linq; using DSharpPlus.Entities; +using PluralKit.Core; + namespace PluralKit.Bot { public class ShortRenderer: IListRenderer { public int MembersPerPage => 25; - public void RenderPage(DiscordEmbedBuilder eb, IEnumerable members) + public void RenderPage(DiscordEmbedBuilder eb, PKSystem system, IEnumerable members) { - eb.Description = string.Join("\n", members.Select(m => + string RenderLine(PKListMember m) { if (m.HasProxyTags) { @@ -24,7 +26,9 @@ namespace PluralKit.Bot } return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}**"; - })); + } + + eb.Description = string.Join("\n", members.Select(RenderLine)); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Lists/SortFilterOptions.cs b/PluralKit.Bot/Lists/SortFilterOptions.cs index 7e1832a2..4be122a6 100644 --- a/PluralKit.Bot/Lists/SortFilterOptions.cs +++ b/PluralKit.Bot/Lists/SortFilterOptions.cs @@ -1,4 +1,5 @@ -using System.Text; +using System; +using System.Text; using PluralKit.Core; @@ -7,45 +8,63 @@ namespace PluralKit.Bot public class SortFilterOptions { public SortProperty SortProperty = SortProperty.Name; - public SortDirection Direction = SortDirection.Ascending; + public bool Reverse = false; public PrivacyFilter PrivacyFilter = PrivacyFilter.PublicOnly; public string Filter = null; public bool SearchInDescription = false; public string CreateFilterString() { - // TODO - return "uwu"; + StringBuilder 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 string BuildQuery() { + // For best performance, add index: + // - `on switch_members using btree (member asc nulls last) include (switch);` + // TODO: add a migration adding this, perhaps lumped with the rest of the DB changes (it's there in prod) + // TODO: also, this should be moved to a view, ideally + // Select clause StringBuilder query = new StringBuilder(); - query.Append(SortProperty switch - { - SortProperty.MessageCount => - "select members.*, count(messages.*) as message_count, max(messages.mid) as last_message from members", - SortProperty.LastMessage => - "select members.*, count(messages.*) as message_count, max(messages.mid) as last_message from members", - SortProperty.LastSwitch => "select members.*, max(switches.timestamp) as last_switch_time from members", - _ => "select members.* from members" - }); + query.Append("select members.*, message_info.*"); + query.Append(", (select max(switches.timestamp) from switch_members inner join switches on switches.id = switch_members.switch where switch_members.member = members.id) as last_switch_time"); + query.Append(" from members"); + + // Join here to enforce index scan on messages table by member, collect both max and count in one swoop + query.Append(" left join lateral (select count(messages.mid) as message_count, max(messages.mid) as last_message from messages where messages.member = members.id) as message_info on true"); - // Join clauses - query.Append(SortProperty switch - { - SortProperty.MessageCount => " left join messages on messages.member = members.id", - SortProperty.LastMessage => " left join messages on messages.member = members.id", - SortProperty.LastSwitch => - " left join switch_members on switch_members.member = members.id left join switches on switch_members.switch = switches.id", - _ => "" - }); - - // Where clauses + // Filtering query.Append(" where members.system = @System"); - - // Privacy filter query.Append(PrivacyFilter switch { PrivacyFilter.PrivateOnly => $" and members.member_privacy = {(int) PrivacyLevel.Private}", @@ -65,72 +84,73 @@ namespace PluralKit.Bot query.Append(")"); } - // Group clause - query.Append(SortProperty switch - { - SortProperty.MessageCount => " group by members.id", - SortProperty.LastMessage => " group by members.id", - SortProperty.LastSwitch => " group by members.id", - _ => "" - }); - // Order clause query.Append(SortProperty switch { + SortProperty.Name => " order by members.name", + SortProperty.DisplayName => " order by members.display_name, members.name", SortProperty.Hid => " order by members.hid", SortProperty.CreationDate => " order by members.created", SortProperty.Birthdate => " order by extract(month from members.birthday), extract(day from members.birthday)", - SortProperty.MessageCount => " order by count(messages.mid)", - SortProperty.LastMessage => " order by max(messages.mid)", - SortProperty.LastSwitch => " order by max(switches.timestamp)", - _ => " order by members.name" + SortProperty.MessageCount => " order by message_count", + SortProperty.LastMessage => " order by last_message", + SortProperty.LastSwitch => " order by last_switch_time", + _ => throw new ArgumentOutOfRangeException($"Couldn't find order clause for sort property {SortProperty}") }); // Order direction - if (Direction == SortDirection.Descending) - query.Append(" desc"); + var direction = SortProperty switch + { + // Some of these make more "logical sense" as descending (ie. "last message" = descending order of message timestamp/ID) + SortProperty.MessageCount => SortDirection.Descending, + SortProperty.LastMessage => SortDirection.Descending, + SortProperty.LastSwitch => SortDirection.Descending, + _ => SortDirection.Ascending + }; + if (Reverse) direction = direction == SortDirection.Ascending ? SortDirection.Descending : SortDirection.Ascending; + query.Append(direction == SortDirection.Ascending ? " asc" : " desc"); + query.Append(" nulls last"); return query.ToString(); } - + public static SortFilterOptions FromFlags(Context ctx) { var p = new SortFilterOptions(); - // Direction - if (ctx.MatchFlag("r", "rev", "reverse", "desc", "descending")) - p.Direction = SortDirection.Descending; + // 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 - if (ctx.MatchFlag("by-id", "bi")) - p.SortProperty = SortProperty.Hid; - else if (ctx.MatchFlag("by-msgcount", "by-mc", "by-msgs", "bm")) - p.SortProperty = SortProperty.MessageCount; - else if (ctx.MatchFlag("by-date", "by-time", "by-created", "bc")) - p.SortProperty = SortProperty.CreationDate; - else if (ctx.MatchFlag("by-switch")) - p.SortProperty = SortProperty.LastSwitch; - else if (ctx.MatchFlag("by-last-message")) - p.SortProperty = SortProperty.LastMessage; - - // Description - if (ctx.MatchFlag("desc")) + // Sort reverse + if (ctx.MatchFlag("r", "rev", "reverse")) + p.Reverse = true; + + // Include description in filter? + if (ctx.MatchFlag("search-description", "filter-description", "in-description", "description", "desc")) p.SearchInDescription = true; - // Privacy - if (ctx.MatchFlag("a", "all")) - p.PrivacyFilter = PrivacyFilter.All; - else if (ctx.MatchFlag("po", "private", "private-only", "only-private", "priv")) - p.PrivacyFilter = PrivacyFilter.PrivateOnly; + // 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, diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 01dc12cf..da4f5ea3 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -6,6 +6,8 @@ using Autofac; using DSharpPlus; using DSharpPlus.EventArgs; +using NodaTime; + using PluralKit.Core; using Sentry; @@ -86,6 +88,7 @@ namespace PluralKit.Bot { Timeout = TimeSpan.FromSeconds(5) }).AsSelf().SingleInstance(); + builder.RegisterInstance(SystemClock.Instance).As(); } } } \ No newline at end of file