From f3869dbcbe6a9f4bbf51109ded7e7f3b8537e0f7 Mon Sep 17 00:00:00 2001 From: rladenson <78043712+rladenson@users.noreply.github.com> Date: Fri, 14 Jan 2022 22:30:02 -0500 Subject: [PATCH] feat: rework group list into member list --- PluralKit.Bot/CommandMeta/CommandHelp.cs | 2 +- .../Context/ContextPrivacyExt.cs | 2 +- PluralKit.Bot/Commands/GroupMember.cs | 6 +- PluralKit.Bot/Commands/Groups.cs | 82 ++++++----- .../Commands/Lists/ContextListExt.cs | 138 +++++++++++++++++- .../{MemberListOptions.cs => ListOptions.cs} | 64 +++++--- PluralKit.Bot/Commands/MemberEdit.cs | 5 +- PluralKit.Bot/Commands/Random.cs | 6 +- PluralKit.Bot/Commands/SystemList.cs | 6 +- PluralKit.Bot/Services/EmbedService.cs | 20 ++- PluralKit.Bot/Utils/ModelUtils.cs | 6 + PluralKit.Core/Database/Migrations/25.sql | 7 + .../Database/Views/DatabaseViewsExt.cs | 36 ++++- PluralKit.Core/Database/Views/ListedGroup.cs | 3 +- PluralKit.Core/Database/Views/views.sql | 9 +- PluralKit.Core/Models/PKGroup.cs | 16 +- PluralKit.Core/Models/Patch/GroupPatch.cs | 20 ++- .../Models/Privacy/GroupPrivacySubject.cs | 12 ++ docs/content/api/models.md | 2 +- docs/content/command-list.md | 2 +- docs/content/tips-and-tricks.md | 56 ++++--- 21 files changed, 374 insertions(+), 126 deletions(-) rename PluralKit.Bot/Commands/Lists/{MemberListOptions.cs => ListOptions.cs} (62%) create mode 100644 PluralKit.Core/Database/Migrations/25.sql diff --git a/PluralKit.Bot/CommandMeta/CommandHelp.cs b/PluralKit.Bot/CommandMeta/CommandHelp.cs index 9feb3bdb..97c0139a 100644 --- a/PluralKit.Bot/CommandMeta/CommandHelp.cs +++ b/PluralKit.Bot/CommandMeta/CommandHelp.cs @@ -57,7 +57,7 @@ public partial class CommandTree public static Command GroupColor = new Command("group color", "group color [color]", "Changes a group's color"); public static Command GroupAdd = new Command("group add", "group add [member 2] [member 3...]", "Adds one or more members to a group"); public static Command GroupRemove = new Command("group remove", "group remove [member 2] [member 3...]", "Removes one or more members from a group"); - public static Command GroupPrivacy = new Command("group privacy", "group privacy ", "Changes a group's privacy settings"); + public static Command GroupPrivacy = new Command("group privacy", "group privacy ", "Changes a group's privacy settings"); public static Command GroupBannerImage = new Command("group banner", "group banner [url]", "Set the group's banner image"); public static Command GroupIcon = new Command("group icon", "group icon [url|@mention]", "Changes a group's icon"); public static Command GroupDelete = new Command("group delete", "group delete", "Deletes a group"); diff --git a/PluralKit.Bot/CommandSystem/Context/ContextPrivacyExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextPrivacyExt.cs index f8fc53d3..ed7b2621 100644 --- a/PluralKit.Bot/CommandSystem/Context/ContextPrivacyExt.cs +++ b/PluralKit.Bot/CommandSystem/Context/ContextPrivacyExt.cs @@ -43,7 +43,7 @@ public static class ContextPrivacyExt { if (!GroupPrivacyUtils.TryParseGroupPrivacy(ctx.PeekArgument(), out var subject)) throw new PKSyntaxError( - $"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `description`, `icon`, `visibility`, or `all`)."); + $"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `icon`, `metadata`, `visibility`, or `all`)."); ctx.PopArgument(); return subject; diff --git a/PluralKit.Bot/Commands/GroupMember.cs b/PluralKit.Bot/Commands/GroupMember.cs index b17f65de..090cc0ca 100644 --- a/PluralKit.Bot/Commands/GroupMember.cs +++ b/PluralKit.Bot/Commands/GroupMember.cs @@ -64,7 +64,7 @@ public class GroupMember var groups = await _repo.GetMemberGroups(target.Id) .Where(g => g.Visibility.CanAccess(pctx)) - .OrderBy(g => g.Name, StringComparer.InvariantCultureIgnoreCase) + .OrderBy(g => (g.DisplayName ?? g.Name), StringComparer.InvariantCultureIgnoreCase) .ToListAsync(); var description = ""; @@ -97,7 +97,7 @@ public class GroupMember .ToList(); var existingMembersInGroup = (await _db.Execute(conn => conn.QueryMemberList(target.System, - new DatabaseViewsExt.MemberListQueryOptions { GroupFilter = target.Id }))) + new DatabaseViewsExt.ListQueryOptions { GroupFilter = target.Id }))) .Select(m => m.Id.Value) .Distinct() .ToHashSet(); @@ -134,7 +134,7 @@ public class GroupMember var targetSystem = await GetGroupSystem(ctx, target); ctx.CheckSystemPrivacy(targetSystem.Id, target.ListPrivacy); - var opts = ctx.ParseMemberListOptions(ctx.DirectLookupContextFor(target.System)); + var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.System)); opts.GroupFilter = target.Id; var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.Hid}`) in "); diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index e8a7ed29..77dfa31c 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -175,6 +175,8 @@ public class Groups await _repo.UpdateGroup(target.Id, patch); await ctx.Reply($"{Emojis.Success} Group display name cleared."); + if (target.NamePrivacy == PrivacyLevel.Private) + await ctx.Reply($"{Emojis.Warn} Since this group no longer has a display name set, their name privacy **can no longer take effect**."); } else { @@ -431,49 +433,33 @@ public class Groups ctx.CheckSystemPrivacy(system.Id, system.GroupListPrivacy); - // TODO: integrate with the normal "search" system + // explanation of privacy lookup here: + // - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list) + // - RenderGroupList checks the indivual privacy for each member (NameFor, etc) + // the own system is always allowed to look up their list + var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(system.Id)); + await ctx.RenderGroupList( + ctx.LookupContextFor(system.Id), + system.Id, + GetEmbedTitle(system, opts), + system.Color, + opts + ); + } - // TODO: integrate with privacy config settings + private string GetEmbedTitle(PKSystem target, ListOptions opts) + { + var title = new StringBuilder("Groups of "); - var pctx = LookupContext.ByNonOwner; - if (ctx.MatchFlag("a", "all")) - { - if (system.Id == ctx.System.Id) - pctx = LookupContext.ByOwner; - else - throw Errors.LookupNotAllowed; - } + if (target.Name != null) + title.Append($"{target.Name} (`{target.Hid}`)"); + else + title.Append($"`{target.Hid}`"); - var groups = (await _db.Execute(conn => conn.QueryGroupList(system.Id))) - .Where(g => g.Visibility.CanAccess(pctx)) - .OrderBy(g => g.Name, StringComparer.InvariantCultureIgnoreCase) - .ToList(); + if (opts.Search != null) + title.Append($" matching **{opts.Search}**"); - if (groups.Count == 0) - { - if (system.Id == ctx.System?.Id) - await ctx.Reply("This system has no groups. To create one, use the command `pk;group new `."); - else - await ctx.Reply("This system has no groups."); - - return; - } - - var title = system.Name != null ? $"Groups of {system.Name} (`{system.Hid}`)" : $"Groups of `{system.Hid}`"; - await ctx.Paginate(groups.ToAsyncEnumerable(), groups.Count, 25, title, system.Color, Renderer); - - Task Renderer(EmbedBuilder eb, IEnumerable page) - { - eb.WithSimpleLineContent(page.Select(g => - { - if (g.DisplayName != null) - return - $"[`{g.Hid}`] **{g.Name.EscapeMarkdown()}** ({g.DisplayName.EscapeMarkdown()}) ({"member".ToQuantity(g.MemberCount)})"; - return $"[`{g.Hid}`] **{g.Name.EscapeMarkdown()}** ({"member".ToQuantity(g.MemberCount)})"; - })); - eb.Footer(new Embed.EmbedFooter($"{groups.Count} total.")); - return Task.CompletedTask; - } + return title.ToString(); } public async Task ShowGroupCard(Context ctx, PKGroup target) @@ -490,12 +476,14 @@ public class Groups { await ctx.Reply(embed: new EmbedBuilder() .Title($"Current privacy settings for {target.Name}") + .Field(new Embed.Field("Name", target.NamePrivacy.Explanation())) .Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation())) .Field(new Embed.Field("Icon", target.IconPrivacy.Explanation())) .Field(new Embed.Field("Member list", target.ListPrivacy.Explanation())) + .Field(new Embed.Field("Metadata (creation date)", target.MetadataPrivacy.Explanation())) .Field(new Embed.Field("Visibility", target.Visibility.Explanation())) .Description( - $"To edit privacy settings, use the command:\n> pk;group **{target.Reference()}** privacy **** ****\n\n- `subject` is one of `description`, `icon`, `members`, `visibility`, or `all`\n- `level` is either `public` or `private`.") + $"To edit privacy settings, use the command:\n> pk;group **{target.Reference()}** privacy **** ****\n\n- `subject` is one of `name`, `description`, `icon`, `members`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.") .Build()); return; } @@ -518,30 +506,40 @@ public class Groups var subjectName = subject switch { + GroupPrivacySubject.Name => "name privacy", GroupPrivacySubject.Description => "description privacy", GroupPrivacySubject.Icon => "icon privacy", GroupPrivacySubject.List => "member list", + GroupPrivacySubject.Metadata => "metadata", GroupPrivacySubject.Visibility => "visibility", _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") }; var explanation = (subject, level) switch { + (GroupPrivacySubject.Name, PrivacyLevel.Private) => + "This group's name is now hidden from other systems, and will be replaced by the group's display name.", (GroupPrivacySubject.Description, PrivacyLevel.Private) => "This group's description is now hidden from other systems.", (GroupPrivacySubject.Icon, PrivacyLevel.Private) => "This group's icon is now hidden from other systems.", (GroupPrivacySubject.Visibility, PrivacyLevel.Private) => "This group is now hidden from group lists and member cards.", + (GroupPrivacySubject.Metadata, PrivacyLevel.Private) => + "This group's metadata (eg. creation date) is now hidden from other systems.", (GroupPrivacySubject.List, PrivacyLevel.Private) => "This group's member list is now hidden from other systems.", + (GroupPrivacySubject.Name, PrivacyLevel.Public) => + "This group's name is no longer hidden from other systems.", (GroupPrivacySubject.Description, PrivacyLevel.Public) => "This group's description is no longer hidden from other systems.", (GroupPrivacySubject.Icon, PrivacyLevel.Public) => "This group's icon is no longer hidden from other systems.", (GroupPrivacySubject.Visibility, PrivacyLevel.Public) => "This group is no longer hidden from group lists and member cards.", + (GroupPrivacySubject.Metadata, PrivacyLevel.Public) => + "This group's metadata (eg. creation date) is no longer hidden from other systems.", (GroupPrivacySubject.List, PrivacyLevel.Public) => "This group's member list is no longer hidden from other systems.", @@ -550,6 +548,10 @@ public class Groups await ctx.Reply( $"{Emojis.Success} {target.Name}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}"); + + if (subject == GroupPrivacySubject.Name && level == PrivacyLevel.Private && target.DisplayName == null) + await ctx.Reply( + $"{Emojis.Warn} This group does not have a display name set, and name privacy **will not take effect**."); } if (ctx.Match("all") || newValueFromCommand != null) diff --git a/PluralKit.Bot/Commands/Lists/ContextListExt.cs b/PluralKit.Bot/Commands/Lists/ContextListExt.cs index b562b105..666805a7 100644 --- a/PluralKit.Bot/Commands/Lists/ContextListExt.cs +++ b/PluralKit.Bot/Commands/Lists/ContextListExt.cs @@ -13,9 +13,9 @@ namespace PluralKit.Bot; public static class ContextListExt { - public static MemberListOptions ParseMemberListOptions(this Context ctx, LookupContext lookupCtx) + public static ListOptions ParseListOptions(this Context ctx, LookupContext lookupCtx) { - var p = new MemberListOptions(); + var p = new ListOptions(); // 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"); @@ -71,7 +71,7 @@ public static class ContextListExt p.IncludeMessageCount = true; if (ctx.MatchFlag("with-created", "wc")) p.IncludeCreated = true; - if (ctx.MatchFlag("with-avatar", "with-image", "wa", "wi", "ia", "ii", "img")) + if (ctx.MatchFlag("with-avatar", "with-image", "with-icon", "wa", "wi", "ia", "ii", "img")) p.IncludeAvatar = true; if (ctx.MatchFlag("with-pronouns", "wp")) p.IncludePronouns = true; @@ -87,7 +87,7 @@ public static class ContextListExt } public static async Task RenderMemberList(this Context ctx, LookupContext lookupCtx, - SystemId system, string embedTitle, string color, MemberListOptions opts) + SystemId system, string embedTitle, string color, ListOptions 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) @@ -248,4 +248,134 @@ public static class ContextListExt } } } + + public static async Task RenderGroupList(this Context ctx, LookupContext lookupCtx, + SystemId system, string embedTitle, string color, ListOptions 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 groups = (await ctx.Database.Execute(conn => conn.QueryGroupList(system, opts.ToQueryOptions()))) + .SortByGroupListOptions(opts, lookupCtx) + .ToList(); + + var itemsPerPage = opts.Type == ListType.Short ? 25 : 5; + await ctx.Paginate(groups.ToAsyncEnumerable(), groups.Count, itemsPerPage, embedTitle, color, Renderer); + + // Base renderer, dispatches based on type + Task Renderer(EmbedBuilder eb, IEnumerable page) + { + // Add a global footer with the filter/sort string + result count + eb.Footer(new Embed.EmbedFooter($"{opts.CreateFilterString()}. {"result".ToQuantity(groups.Count)}.")); + + // Then call the specific renderers + if (opts.Type == ListType.Short) + ShortRenderer(eb, page); + else + LongRenderer(eb, page); + + return Task.CompletedTask; + } + + void ShortRenderer(EmbedBuilder 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(g => + { + var ret = $"[`{g.Hid}`] **{g.NameFor(ctx)}** "; + + switch (opts.SortProperty) + { + case SortProperty.DisplayName: + { + if (g.NamePrivacy.CanAccess(lookupCtx) && g.DisplayName != null) + ret += $"({g.DisplayName})"; + break; + } + case SortProperty.CreationDate: + { + if (g.MetadataPrivacy.TryGet(lookupCtx, g.Created, out var created)) + ret += $"(created at )"; + break; + } + default: + { + if (opts.IncludeCreated && + g.MetadataPrivacy.TryGet(lookupCtx, g.Created, out var created)) + { + ret += $"(created at )"; + } + else if (opts.IncludeAvatar && g.IconFor(lookupCtx) is { } avatarUrl) + { + ret += $"([avatar URL]({avatarUrl}))"; + } + else + { + // -priv/-pub and listprivacy affects whether count is shown + // -all and visibility affects what the count is + if (ctx.DirectLookupContextFor(system) == LookupContext.ByOwner) + { + if (g.ListPrivacy == PrivacyLevel.Public || lookupCtx == LookupContext.ByOwner) + { + if (ctx.MatchFlag("all", "a")) + { + ret += $"({"member".ToQuantity(g.TotalMemberCount)})"; + } + else + { + ret += $"({"member".ToQuantity(g.PublicMemberCount)})"; + } + } + } + else + { + if (g.ListPrivacy == PrivacyLevel.Public) + { + ret += $"({"member".ToQuantity(g.PublicMemberCount)})"; + } + } + } + + break; + } + } + + return ret; + })); + } + + void LongRenderer(EmbedBuilder eb, IEnumerable page) + { + foreach (var g in page) + { + var profile = new StringBuilder($"**ID**: {g.Hid}"); + + if (g.DisplayName != null && g.NamePrivacy.CanAccess(lookupCtx)) + profile.Append($"\n**Display name**: {g.DisplayName}"); + + if (g.ListPrivacy == PrivacyLevel.Public || lookupCtx == LookupContext.ByOwner) + { + if (ctx.MatchFlag("all", "a") && ctx.DirectLookupContextFor(system) == LookupContext.ByOwner) + profile.Append($"\n**Member Count:** {g.TotalMemberCount}"); + else + profile.Append($"\n**Member Count:** {g.PublicMemberCount}"); + } + + if ((opts.IncludeCreated || opts.SortProperty == SortProperty.CreationDate) && + g.MetadataPrivacy.TryGet(lookupCtx, g.Created, out var created)) + profile.Append($"\n**Created on:** {created.FormatZoned(ctx.Zone)}"); + + if (opts.IncludeAvatar && g.IconFor(lookupCtx) is { } avatar) + profile.Append($"\n**Avatar URL:** {avatar.TryGetCleanCdnUrl()}"); + + if (g.DescriptionFor(lookupCtx) is { } desc) + profile.Append($"\n\n{desc}"); + + if (g.Visibility == PrivacyLevel.Private) + profile.Append("\n*(this member is hidden)*"); + + eb.Field(new Embed.Field(g.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/ListOptions.cs similarity index 62% rename from PluralKit.Bot/Commands/Lists/MemberListOptions.cs rename to PluralKit.Bot/Commands/Lists/ListOptions.cs index 96b4f87a..7c64e556 100644 --- a/PluralKit.Bot/Commands/Lists/MemberListOptions.cs +++ b/PluralKit.Bot/Commands/Lists/ListOptions.cs @@ -7,7 +7,7 @@ using PluralKit.Core; #nullable enable namespace PluralKit.Bot; -public class MemberListOptions +public class ListOptions { public SortProperty SortProperty { get; set; } = SortProperty.Name; public bool Reverse { get; set; } @@ -32,8 +32,8 @@ public class MemberListOptions if (SortProperty != SortProperty.Random) str.Append("by "); str.Append(SortProperty switch { - SortProperty.Name => "member name", - SortProperty.Hid => "member ID", + SortProperty.Name => "name", + SortProperty.Hid => "ID", SortProperty.DisplayName => "display name", SortProperty.CreationDate => "creation date", SortProperty.LastMessage => "last message", @@ -52,8 +52,8 @@ public class MemberListOptions str.Append(PrivacyFilter switch { - null => ", showing all members", - PrivacyLevel.Private => ", showing only private members", + null => ", showing all items", + PrivacyLevel.Private => ", showing only private items", PrivacyLevel.Public => "", // (default, no extra line needed) _ => new ArgumentOutOfRangeException( $"Couldn't find readable string for privacy filter {PrivacyFilter}") @@ -62,7 +62,7 @@ public class MemberListOptions return str.ToString(); } - public DatabaseViewsExt.MemberListQueryOptions ToQueryOptions() => + public DatabaseViewsExt.ListQueryOptions ToQueryOptions() => new() { PrivacyFilter = PrivacyFilter, @@ -72,10 +72,10 @@ public class MemberListOptions }; } -public static class MemberListOptionsExt +public static class ListOptionsExt { public static IEnumerable SortByMemberListOptions(this IEnumerable input, - MemberListOptions opts, LookupContext ctx) + ListOptions opts, LookupContext ctx) { IComparer ReverseMaybe(IComparer c) => opts.Reverse ? Comparer.Create((a, b) => c.Compare(b, a)) : c; @@ -89,26 +89,23 @@ public static class MemberListOptionsExt // 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 - .OrderByDescending(m => m.MetadataPrivacy.CanAccess(ctx)) - .ThenBy(m => m.MetadataPrivacy.Get(ctx, m.Created, default), ReverseMaybe(Comparer.Default)), - SortProperty.MessageCount => input - .OrderByDescending(m => m.MessageCount != 0 && m.MetadataPrivacy.CanAccess(ctx)) - .ThenByDescending(m => m.MetadataPrivacy.Get(ctx, m.MessageCount, 0), ReverseMaybe(Comparer.Default)), + 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 && m.NamePrivacy.CanAccess(ctx)) - .ThenBy(m => m.NamePrivacy.Get(ctx, m.DisplayName), ReverseMaybe(culture)), + .OrderByDescending(m => m.DisplayName != null) + .ThenBy(m => m.DisplayName, ReverseMaybe(culture)), SortProperty.Birthdate => input - .OrderByDescending(m => m.AnnualBirthday.HasValue && m.BirthdayPrivacy.CanAccess(ctx)) - .ThenBy(m => m.BirthdayPrivacy.Get(ctx, m.AnnualBirthday), ReverseMaybe(Comparer.Default)), + .OrderByDescending(m => m.AnnualBirthday.HasValue) + .ThenBy(m => m.AnnualBirthday, ReverseMaybe(Comparer.Default)), SortProperty.LastMessage => throw new PKError( "Sorting by last message is temporarily disabled due to database issues, sorry."), // SortProperty.LastMessage => input // .OrderByDescending(m => m.LastMessage.HasValue) // .ThenByDescending(m => m.LastMessage, ReverseMaybe(Comparer.Default)), SortProperty.LastSwitch => input - .OrderByDescending(m => m.LastSwitchTime.HasValue && m.MetadataPrivacy.CanAccess(ctx)) - .ThenByDescending(m => m.MetadataPrivacy.Get(ctx, m.LastSwitchTime), ReverseMaybe(Comparer.Default)), + .OrderByDescending(m => m.LastSwitchTime.HasValue) + .ThenByDescending(m => m.LastSwitchTime, ReverseMaybe(Comparer.Default)), SortProperty.Random => input .OrderBy(m => randGen.Next()), _ => throw new ArgumentOutOfRangeException($"Unknown sort property {opts.SortProperty}") @@ -116,6 +113,33 @@ public static class MemberListOptionsExt // Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values) .ThenBy(m => m.NameFor(ctx), culture); } + + public static IEnumerable SortByGroupListOptions(this IEnumerable input, + ListOptions opts, LookupContext ctx) + { + IComparer ReverseMaybe(IComparer c) => + opts.Reverse ? Comparer.Create((a, b) => c.Compare(b, a)) : c; + + var randGen = new global::System.Random(); + + 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(g => g.Hid, ReverseMaybe(culture)), + SortProperty.Name => input.OrderBy(g => g.NameFor(ctx), ReverseMaybe(culture)), + SortProperty.CreationDate => input.OrderBy(g => g.Created, ReverseMaybe(Comparer.Default)), + SortProperty.DisplayName => input + .OrderByDescending(g => g.DisplayName != null) + .ThenBy(g => g.DisplayName, ReverseMaybe(culture)), + SortProperty.Random => input + .OrderBy(g => randGen.Next()), + _ => 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 diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 5f8be171..076b319c 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -415,7 +415,10 @@ public class MemberEdit await _repo.UpdateMember(target.Id, patch); await PrintSuccess( - $"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.NameFor(ctx)}\"."); + $"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.Name}\"."); + + if (target.NamePrivacy == PrivacyLevel.Private) + await ctx.Reply($"{Emojis.Warn} Since this member no longer has a display name set, their name privacy **can no longer take effect**."); } else { diff --git a/PluralKit.Bot/Commands/Random.cs b/PluralKit.Bot/Commands/Random.cs index a2876c10..ab26602f 100644 --- a/PluralKit.Bot/Commands/Random.cs +++ b/PluralKit.Bot/Commands/Random.cs @@ -41,9 +41,9 @@ public class Random { ctx.CheckSystem(); - var groups = await _db.Execute(c => c.QueryGroupList(ctx.System.Id)); + var groups = await _repo.GetSystemGroups(ctx.System.Id).ToListAsync(); if (!ctx.MatchFlag("all", "a")) - groups = groups.Where(g => g.Visibility == PrivacyLevel.Public); + groups = groups.Where(g => g.Visibility == PrivacyLevel.Public).ToList(); if (groups == null || !groups.Any()) throw new PKError( @@ -57,7 +57,7 @@ public class Random { ctx.CheckOwnGroup(group); - var opts = ctx.ParseMemberListOptions(ctx.DirectLookupContextFor(group.System)); + var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(group.System)); opts.GroupFilter = group.Id; await using var conn = await _db.Obtain(); diff --git a/PluralKit.Bot/Commands/SystemList.cs b/PluralKit.Bot/Commands/SystemList.cs index 9f8c694b..88111684 100644 --- a/PluralKit.Bot/Commands/SystemList.cs +++ b/PluralKit.Bot/Commands/SystemList.cs @@ -12,10 +12,10 @@ public class SystemList ctx.CheckSystemPrivacy(target.Id, target.MemberListPrivacy); // explanation of privacy lookup here: - // - ParseMemberListOptions checks list access privacy and sets the privacy filter (which members show up in list) + // - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list) // - RenderMemberList checks the indivual privacy for each member (NameFor, etc) // the own system is always allowed to look up their list - var opts = ctx.ParseMemberListOptions(ctx.DirectLookupContextFor(target.Id)); + var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.Id)); await ctx.RenderMemberList( ctx.LookupContextFor(target.Id), target.Id, @@ -25,7 +25,7 @@ public class SystemList ); } - private string GetEmbedTitle(PKSystem target, MemberListOptions opts) + private string GetEmbedTitle(PKSystem target, ListOptions opts) { var title = new StringBuilder("Members of "); diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 3bd8d159..c101cade 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -245,7 +245,7 @@ public class EmbedService var memberCount = await _repo.GetGroupMemberCount(target.Id, countctx == LookupContext.ByOwner ? null : PrivacyLevel.Public); - var nameField = target.Name; + var nameField = target.NamePrivacy.Get(pctx, target.Name, target.DisplayName ?? target.Name); if (system.Name != null) nameField = $"{nameField} ({system.Name})"; @@ -262,14 +262,14 @@ public class EmbedService var eb = new EmbedBuilder() .Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx))) - .Color(color) - .Footer(new Embed.EmbedFooter( - $"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(ctx.Zone)}")); + .Color(color); - if (target.DescriptionPrivacy.CanAccess(ctx.LookupContextFor(target.System))) + eb.Footer(new Embed.EmbedFooter($"System ID: {system.Hid} | Group ID: {target.Hid}{(target.MetadataPrivacy.CanAccess(pctx) ? $" | Created on {target.Created.FormatZoned(ctx.Zone)}" : "")}")); + + if (target.DescriptionPrivacy.CanAccess(pctx)) eb.Image(new Embed.EmbedImage(target.BannerImage)); - if (target.DisplayName != null) + if (target.NamePrivacy.CanAccess(pctx) && target.DisplayName != null) eb.Field(new Embed.Field("Display Name", target.DisplayName, true)); if (!target.Color.EmptyOrNull()) eb.Field(new Embed.Field("Color", $"#{target.Color}", true)); @@ -281,8 +281,12 @@ public class EmbedService eb.Field(new Embed.Field("Members (0)", $"Add one with `pk;group {target.Reference()} add `!")); else - eb.Field(new Embed.Field($"Members ({memberCount})", - $"(see `pk;group {target.Reference()} list`)")); + { + var name = pctx == LookupContext.ByOwner + ? target.Reference() + : target.Hid; + eb.Field(new Embed.Field($"Members ({memberCount})", $"(see `pk;group {name} list`)")); + } } if (target.DescriptionFor(pctx) is { } desc) diff --git a/PluralKit.Bot/Utils/ModelUtils.cs b/PluralKit.Bot/Utils/ModelUtils.cs index dae85ac9..fa7a750b 100644 --- a/PluralKit.Bot/Utils/ModelUtils.cs +++ b/PluralKit.Bot/Utils/ModelUtils.cs @@ -9,9 +9,15 @@ public static class ModelUtils public static string NameFor(this PKMember member, Context ctx) => member.NameFor(ctx.LookupContextFor(member.System)); + public static string NameFor(this PKGroup group, Context ctx) => + group.NameFor(ctx.LookupContextFor(group.System)); + public static string AvatarFor(this PKMember member, Context ctx) => member.AvatarFor(ctx.LookupContextFor(member.System)).TryGetCleanCdnUrl(); + public static string IconFor(this PKGroup group, Context ctx) => + group.IconFor(ctx.LookupContextFor(group.System)).TryGetCleanCdnUrl(); + public static string DisplayName(this PKMember member) => member.DisplayName ?? member.Name; diff --git a/PluralKit.Core/Database/Migrations/25.sql b/PluralKit.Core/Database/Migrations/25.sql new file mode 100644 index 00000000..56860322 --- /dev/null +++ b/PluralKit.Core/Database/Migrations/25.sql @@ -0,0 +1,7 @@ +-- schema version 25 +-- group name privacy + +alter table groups add column name_privacy integer check (name_privacy in (1, 2)) not null default 1; +alter table groups add column metadata_privacy integer check (metadata_privacy in (1, 2)) not null default 1; + +update info set schema_version = 25; \ No newline at end of file diff --git a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs index b6a08eef..4e748d2e 100644 --- a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs +++ b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs @@ -10,11 +10,39 @@ public static class DatabaseViewsExt public static Task> QueryCurrentFronters(this IPKConnection conn, SystemId system) => conn.QueryAsync("select * from system_fronters where system = @system", new { system }); - public static Task> QueryGroupList(this IPKConnection conn, SystemId system) => - conn.QueryAsync("select * from group_list where system = @System", new { System = system }); + public static Task> QueryGroupList(this IPKConnection conn, SystemId system, + ListQueryOptions opts) + { + StringBuilder query = new StringBuilder("select * from group_list where system = @system"); + if (opts.PrivacyFilter != null) + query.Append($" and visibility = {(int)opts.PrivacyFilter}"); + + 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 (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 = + opts.Context == LookupContext.ByOwner ? "description" : "public_description"; + query.Append($"or {Filter(descriptionColumn)}"); + } + + query.Append(")"); + } + + return conn.QueryAsync( + query.ToString(), + new { system, filter = opts.Search }); + } public static Task> QueryMemberList(this IPKConnection conn, SystemId system, - MemberListQueryOptions opts) + ListQueryOptions opts) { StringBuilder query; if (opts.GroupFilter == null) @@ -49,7 +77,7 @@ public static class DatabaseViewsExt new { system, filter = opts.Search, groupFilter = opts.GroupFilter }); } - public struct MemberListQueryOptions + public struct ListQueryOptions { public PrivacyLevel? PrivacyFilter; public string? Search; diff --git a/PluralKit.Core/Database/Views/ListedGroup.cs b/PluralKit.Core/Database/Views/ListedGroup.cs index 50e99a48..f66c54d3 100644 --- a/PluralKit.Core/Database/Views/ListedGroup.cs +++ b/PluralKit.Core/Database/Views/ListedGroup.cs @@ -2,5 +2,6 @@ namespace PluralKit.Core; public class ListedGroup: PKGroup { - public int MemberCount { get; } + public int PublicMemberCount { get; } + public int TotalMemberCount { get; } } \ No newline at end of file diff --git a/PluralKit.Core/Database/Views/views.sql b/PluralKit.Core/Database/Views/views.sql index 310ec9fb..b62637afb 100644 --- a/PluralKit.Core/Database/Views/views.sql +++ b/PluralKit.Core/Database/Views/views.sql @@ -64,5 +64,12 @@ select groups.*, inner join members on group_members.member_id = members.id where group_members.group_id = groups.id and members.member_visibility = 1 - ) as member_count + ) as public_member_count, + -- Find private group member count + ( + select count(*) from group_members + inner join members on group_members.member_id = members.id + where + group_members.group_id = groups.id + ) as total_member_count from groups; \ No newline at end of file diff --git a/PluralKit.Core/Models/PKGroup.cs b/PluralKit.Core/Models/PKGroup.cs index 05ea30e4..53f5f704 100644 --- a/PluralKit.Core/Models/PKGroup.cs +++ b/PluralKit.Core/Models/PKGroup.cs @@ -43,9 +43,11 @@ public class PKGroup public string? BannerImage { get; private set; } public string? Color { get; private set; } + public PrivacyLevel NamePrivacy { get; private set; } public PrivacyLevel DescriptionPrivacy { get; private set; } public PrivacyLevel IconPrivacy { get; private set; } public PrivacyLevel ListPrivacy { get; private set; } + public PrivacyLevel MetadataPrivacy { get; private set; } public PrivacyLevel Visibility { get; private set; } public Instant Created { get; private set; } @@ -53,12 +55,18 @@ public class PKGroup public static class PKGroupExt { + public static string? NameFor(this PKGroup group, LookupContext ctx) => + group.NamePrivacy.Get(ctx, group.Name, group.DisplayName ?? group.Name); + public static string? DescriptionFor(this PKGroup group, LookupContext ctx) => group.DescriptionPrivacy.Get(ctx, group.Description); public static string? IconFor(this PKGroup group, LookupContext ctx) => group.IconPrivacy.Get(ctx, group.Icon?.TryGetCleanCdnUrl()); + public static Instant? CreatedFor(this PKGroup group, LookupContext ctx) => + group.MetadataPrivacy.Get(ctx, (Instant?)group.Created); + public static JObject ToJson(this PKGroup group, LookupContext ctx, string? systemStr = null, bool needsMembersArray = false) { @@ -66,18 +74,18 @@ public static class PKGroupExt o.Add("id", group.Hid); o.Add("uuid", group.Uuid.ToString()); - o.Add("name", group.Name); + o.Add("name", group.NameFor(ctx)); if (systemStr != null) o.Add("system", systemStr); - o.Add("display_name", group.DisplayName); + o.Add("display_name", group.NamePrivacy.CanAccess(ctx) ? group.DisplayName : null); o.Add("description", group.DescriptionPrivacy.Get(ctx, group.Description)); o.Add("icon", group.IconFor(ctx)); o.Add("banner", group.DescriptionPrivacy.Get(ctx, group.BannerImage)); o.Add("color", group.Color); - o.Add("created", group.Created.FormatExport()); + o.Add("created", group.CreatedFor(ctx)?.FormatExport()); if (needsMembersArray) o.Add("members", new JArray()); @@ -86,9 +94,11 @@ public static class PKGroupExt { var p = new JObject(); + p.Add("name_privacy", group.NamePrivacy.ToJsonString()); p.Add("description_privacy", group.DescriptionPrivacy.ToJsonString()); p.Add("icon_privacy", group.IconPrivacy.ToJsonString()); p.Add("list_privacy", group.ListPrivacy.ToJsonString()); + p.Add("metadata_privacy", group.MetadataPrivacy.ToJsonString()); p.Add("visibility", group.Visibility.ToJsonString()); o.Add("privacy", p); diff --git a/PluralKit.Core/Models/Patch/GroupPatch.cs b/PluralKit.Core/Models/Patch/GroupPatch.cs index 8cefa851..de90e9fc 100644 --- a/PluralKit.Core/Models/Patch/GroupPatch.cs +++ b/PluralKit.Core/Models/Patch/GroupPatch.cs @@ -15,9 +15,11 @@ public class GroupPatch: PatchObject public Partial BannerImage { get; set; } public Partial Color { get; set; } + public Partial NamePrivacy { get; set; } public Partial DescriptionPrivacy { get; set; } public Partial IconPrivacy { get; set; } public Partial ListPrivacy { get; set; } + public Partial MetadataPrivacy { get; set; } public Partial Visibility { get; set; } public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper @@ -28,9 +30,11 @@ public class GroupPatch: PatchObject .With("icon", Icon) .With("banner_image", BannerImage) .With("color", Color) + .With("name_privacy", NamePrivacy) .With("description_privacy", DescriptionPrivacy) .With("icon_privacy", IconPrivacy) .With("list_privacy", ListPrivacy) + .With("metadata_privacy", MetadataPrivacy) .With("visibility", Visibility) ); @@ -74,6 +78,9 @@ public class GroupPatch: PatchObject { var privacy = o.Value("privacy"); + if (privacy.ContainsKey("name_privacy")) + patch.NamePrivacy = patch.ParsePrivacy(privacy, "name_privacy"); + if (privacy.ContainsKey("description_privacy")) patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "description_privacy"); @@ -83,6 +90,9 @@ public class GroupPatch: PatchObject if (privacy.ContainsKey("list_privacy")) patch.ListPrivacy = patch.ParsePrivacy(privacy, "list_privacy"); + if (privacy.ContainsKey("metadata_privacy")) + patch.MetadataPrivacy = patch.ParsePrivacy(privacy, "metadata_privacy"); + if (privacy.ContainsKey("visibility")) patch.Visibility = patch.ParsePrivacy(privacy, "visibility"); } @@ -110,14 +120,19 @@ public class GroupPatch: PatchObject o.Add("color", Color.Value); if ( - DescriptionPrivacy.IsPresent + NamePrivacy.IsPresent + || DescriptionPrivacy.IsPresent || IconPrivacy.IsPresent || ListPrivacy.IsPresent + || MetadataPrivacy.IsPresent || Visibility.IsPresent ) { var p = new JObject(); + if (NamePrivacy.IsPresent) + p.Add("name_privacy", NamePrivacy.Value.ToJsonString()); + if (DescriptionPrivacy.IsPresent) p.Add("description_privacy", DescriptionPrivacy.Value.ToJsonString()); @@ -127,6 +142,9 @@ public class GroupPatch: PatchObject if (ListPrivacy.IsPresent) p.Add("list_privacy", ListPrivacy.Value.ToJsonString()); + if (MetadataPrivacy.IsPresent) + p.Add("metadata_privacy", MetadataPrivacy.Value.ToJsonString()); + if (Visibility.IsPresent) p.Add("visibility", Visibility.Value.ToJsonString()); diff --git a/PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs b/PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs index c2974a12..24e1f2b1 100644 --- a/PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs +++ b/PluralKit.Core/Models/Privacy/GroupPrivacySubject.cs @@ -2,9 +2,11 @@ namespace PluralKit.Core; public enum GroupPrivacySubject { + Name, Description, Icon, List, + Metadata, Visibility } @@ -15,9 +17,11 @@ public static class GroupPrivacyUtils // what do you mean switch expressions can't be statements >.> _ = subject switch { + GroupPrivacySubject.Name => group.NamePrivacy = level, GroupPrivacySubject.Description => group.DescriptionPrivacy = level, GroupPrivacySubject.Icon => group.IconPrivacy = level, GroupPrivacySubject.List => group.ListPrivacy = level, + GroupPrivacySubject.Metadata => group.MetadataPrivacy = level, GroupPrivacySubject.Visibility => group.Visibility = level, _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") }; @@ -36,6 +40,9 @@ public static class GroupPrivacyUtils { switch (input.ToLowerInvariant()) { + case "name": + subject = GroupPrivacySubject.Name; + break; case "description": case "desc": case "text": @@ -54,6 +61,11 @@ public static class GroupPrivacyUtils case "visible": subject = GroupPrivacySubject.Visibility; break; + case "meta": + case "metadata": + case "created": + subject = GroupPrivacySubject.Metadata; + break; case "list": case "listing": case "members": diff --git a/docs/content/api/models.md b/docs/content/api/models.md index 76a02714..f8737a63 100644 --- a/docs/content/api/models.md +++ b/docs/content/api/models.md @@ -76,7 +76,7 @@ Every PluralKit entity has two IDs: a short (5-character) ID and a longer UUID. |color|string|6-character hex code, no `#` at the beginning| |privacy|?group privacy object|| -* Group privacy keys: `description_privacy`, `icon_privacy`, `list_privacy`, `visibility` +* Group privacy keys: `name_privacy`, `description_privacy`, `icon_privacy`, `list_privacy`, `metadata_privacy`, `visibility` ### Switch model diff --git a/docs/content/command-list.md b/docs/content/command-list.md index c4ec9816..ebce0c80 100644 --- a/docs/content/command-list.md +++ b/docs/content/command-list.md @@ -89,7 +89,7 @@ Some arguments indicate the use of specific Discord features. These include: - `pk;group description [description]` - Shows or changes a group's description. - `pk;group add [member 2] [member 3...]` - Adds one or more members to a group. - `pk;group remove [member 2] [member 3...]` - Removes one or more members from a group. -- `pk;group privacy ` - Changes a group's privacy settings. +- `pk;group privacy ` - Changes a group's privacy settings. - `pk;group icon [icon url|@mention|upload]` - Shows or changes a group's icon. - `pk;group banner [image url|upload]` - Shows or changes a group's banner image. - `pk;group delete` - Deletes a group. diff --git a/docs/content/tips-and-tricks.md b/docs/content/tips-and-tricks.md index 8095982d..e2266097 100644 --- a/docs/content/tips-and-tricks.md +++ b/docs/content/tips-and-tricks.md @@ -32,47 +32,43 @@ PluralKit has a couple of useful command shorthands to reduce the typing: There are a number of option flags that can be added to the `pk;system list` command. ### Sorting options -|Flag|Aliases|Description| -|---|---|---| -|-by-name|-bn|Sort by member name (default)| -|-by-display-name|-bdn|Sort by display name| -|-by-id|-bid|Sort by member ID| -|-by-message-count|-bmc|Sort by message count (members with the most messages will appear near the top)| -|-by-created|-bc|Sort by creation date (members least recently created will appear near the top)| -|-by-last-fronted|-by-last-front, -by-last-switch, -blf, -bls|Sort by most recently fronted| -|-by-last-message|-blm, -blp|Sort by last message time (members who most recently sent a proxied message will appear near the top)| -|-by-birthday|-by-birthdate, -bbd|Sort by birthday (members whose birthday is in January will appear near the top)| -|-reverse|-rev, -r|Reverse previously chosen sorting order| -|-random||Sort randomly| +|Flag|Aliases|Lists|Description| +|---|---|---|---| +|-by-name|-bn|Member, Group|Sort by name (default)| +|-by-display-name|-bdn|Member, Group|Sort by display name| +|-by-id|-bid|Member, Group|Sort by ID| +|-by-message-count|-bmc|Member|Sort by message count (members with the most messages will appear near the top)| +|-by-created|-bc|Member, Group|Sort by creation date (least recently created will appear near the top)| +|-by-last-fronted|-by-last-front, -by-last-switch, -blf, -bls|Member|Sort by most recently fronted| +|-by-last-message|-blm, -blp|Member|Sort by last message time (members who most recently sent a proxied message will appear near the top)| +|-by-birthday|-by-birthdate, -bbd|Member|Sort by birthday (members whose birthday is in January will appear near the top)| +|-reverse|-rev, -r|Member, Group|Reverse previously chosen sorting order| +|-random||Member, Group|Sort randomly| ### Filter options -|Flag|Aliases|Description| -|---|---|---| -|-all|-a|Show all members, including private members| -|-private-only|-po|Only show private members| +|Flag|Aliases|Lists|Description| +|---|---|---|---| +|-all|-a|Member, Group|Show all members/groups, including private members/groups| +|-private-only|-po|Member, Group|Only show private members/groups| ::: warning -You cannot look up private members of another system. +You cannot look up private members or groups of another system. ::: ### Additional fields to include in the search results -|Flag|Aliases|Description| -|---|---|---| -|-with-last-switch|-with-last-fronted, -with-last-front, -wls, -wlf|Show each member's last switch date| -|-with-last-message|-with-last-proxy, -wlm, -wlp|Show each member's last message date| -|-with-message-count|-wmc|Show each member's message count| -|-with-created|-wc|Show each member's creation date| -|-with-avatar|-wa, -wi, -ia, -ii, -img|Show each member's avatar URL| -|-with-pronouns|-wp|Show each member's pronouns in the short list (shown by default in full list)| - -::: warning -These flags only work with the full member list (`pk;system list full`). -::: +|Flag|Aliases|Lists|Description| +|---|---|---|---| +|-with-last-switch|-with-last-fronted, -with-last-front, -wls, -wlf|Member|Show each member's last switch date| +|-with-last-message|-with-last-proxy, -wlm, -wlp|Member|Show each member's last message date| +|-with-message-count|-wmc|Member|Show each member's message count| +|-with-created|-wc|Member, Group|Show each item's creation date| +|-with-avatar|-wa, -wi, -ia, -ii, -img|Member, Group|Show each item's avatar URL| +|-with-pronouns|-wp|Member|Show each member's pronouns in the short list (shown by default in full list)| ## Miscellaneous flags |Command|Flag|Aliases|Description| |---|---|---|---| -|pk;system list|-search-description|-sd|Search inside descriptions instead of member names| +|List commands|-search-description|-sd|Search inside descriptions instead of member/group names| |pk;system frontpercent|-fronters-only|-fo|Show the system's frontpercent without the "no fronter" entry| |pk;system frontpercent|-flat||Show "flat" frontpercent - percentages add up to 100%| |pk;group \ frontpercent|-fronters-only|-fo|Show a group's frontpercent without the "no fronter" entry|