From 2b581473c89fce86695becc47b230ca66f800c46 Mon Sep 17 00:00:00 2001 From: Toastie <toastie@toastiet0ast.com> Date: Wed, 12 Mar 2025 12:13:02 +1300 Subject: [PATCH] added .clubbanner command improved userrole slightly --- src/EllieBot/Db/Models/club/ClubInfo.cs | 3 +- .../20250310143026_club-banners.sql | 7 + ...ner.cs => 20250310143051_init.Designer.cs} | 6 +- ...0101144_init.cs => 20250310143051_init.cs} | 1 + .../PostgreSqlContextModelSnapshot.cs | 4 + .../Sqlite/20250310143023_club-banners.sql | 7 + ...ner.cs => 20250310143048_init.Designer.cs} | 5 +- ...0101142_init.cs => 20250310143048_init.cs} | 1 + .../Sqlite/SqliteContextModelSnapshot.cs | 3 + .../Utility/UserRole/UserRoleCommands.cs | 13 +- src/EllieBot/Modules/Xp/Club/Club.cs | 204 ++++++++++-------- src/EllieBot/Modules/Xp/Club/ClubService.cs | 20 ++ src/EllieBot/Modules/Xp/Club/IClubService.cs | 1 + .../strings/commands/commands.en-US.yml | 37 ++-- .../strings/responses/responses.en-US.json | 4 +- 15 files changed, 209 insertions(+), 107 deletions(-) create mode 100644 src/EllieBot/Migrations/PostgreSql/20250310143026_club-banners.sql rename src/EllieBot/Migrations/PostgreSql/{20250310101144_init.Designer.cs => 20250310143051_init.Designer.cs} (99%) rename src/EllieBot/Migrations/PostgreSql/{20250310101144_init.cs => 20250310143051_init.cs} (99%) create mode 100644 src/EllieBot/Migrations/Sqlite/20250310143023_club-banners.sql rename src/EllieBot/Migrations/Sqlite/{20250310101142_init.Designer.cs => 20250310143048_init.Designer.cs} (99%) rename src/EllieBot/Migrations/Sqlite/{20250310101142_init.cs => 20250310143048_init.cs} (99%) diff --git a/src/EllieBot/Db/Models/club/ClubInfo.cs b/src/EllieBot/Db/Models/club/ClubInfo.cs index a7bc16f..6c13a99 100644 --- a/src/EllieBot/Db/Models/club/ClubInfo.cs +++ b/src/EllieBot/Db/Models/club/ClubInfo.cs @@ -9,7 +9,8 @@ public class ClubInfo : DbEntity public string Name { get; set; } public string Description { get; set; } public string ImageUrl { get; set; } = string.Empty; - + public string BannerUrl { get; set; } = string.Empty; + public int Xp { get; set; } = 0; public int? OwnerId { get; set; } public DiscordUser Owner { get; set; } diff --git a/src/EllieBot/Migrations/PostgreSql/20250310143026_club-banners.sql b/src/EllieBot/Migrations/PostgreSql/20250310143026_club-banners.sql new file mode 100644 index 0000000..86aca32 --- /dev/null +++ b/src/EllieBot/Migrations/PostgreSql/20250310143026_club-banners.sql @@ -0,0 +1,7 @@ +START TRANSACTION; +ALTER TABLE clubs ADD bannerurl text; + +INSERT INTO "__EFMigrationsHistory" (migrationid, productversion) +VALUES ('20250310143026_club-banners', '9.0.1'); + +COMMIT; diff --git a/src/EllieBot/Migrations/PostgreSql/20250310101144_init.Designer.cs b/src/EllieBot/Migrations/PostgreSql/20250310143051_init.Designer.cs similarity index 99% rename from src/EllieBot/Migrations/PostgreSql/20250310101144_init.Designer.cs rename to src/EllieBot/Migrations/PostgreSql/20250310143051_init.Designer.cs index 8ecf1ff..9579cef 100644 --- a/src/EllieBot/Migrations/PostgreSql/20250310101144_init.Designer.cs +++ b/src/EllieBot/Migrations/PostgreSql/20250310143051_init.Designer.cs @@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace EllieBot.Migrations.PostgreSql { [DbContext(typeof(PostgreSqlContext))] - [Migration("20250310101144_init")] + [Migration("20250310143051_init")] partial class init { /// <inheritdoc /> @@ -572,6 +572,10 @@ namespace EllieBot.Migrations.PostgreSql NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + b.Property<string>("BannerUrl") + .HasColumnType("text") + .HasColumnName("bannerurl"); + b.Property<DateTime?>("DateAdded") .HasColumnType("timestamp without time zone") .HasColumnName("dateadded"); diff --git a/src/EllieBot/Migrations/PostgreSql/20250310101144_init.cs b/src/EllieBot/Migrations/PostgreSql/20250310143051_init.cs similarity index 99% rename from src/EllieBot/Migrations/PostgreSql/20250310101144_init.cs rename to src/EllieBot/Migrations/PostgreSql/20250310143051_init.cs index 22e4c85..cedcdf0 100644 --- a/src/EllieBot/Migrations/PostgreSql/20250310101144_init.cs +++ b/src/EllieBot/Migrations/PostgreSql/20250310143051_init.cs @@ -1601,6 +1601,7 @@ namespace EllieBot.Migrations.PostgreSql name = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true), description = table.Column<string>(type: "text", nullable: true), imageurl = table.Column<string>(type: "text", nullable: true), + bannerurl = table.Column<string>(type: "text", nullable: true), xp = table.Column<int>(type: "integer", nullable: false), ownerid = table.Column<int>(type: "integer", nullable: true), dateadded = table.Column<DateTime>(type: "timestamp without time zone", nullable: true) diff --git a/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs index cbc6220..4a7d2a4 100644 --- a/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs +++ b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs @@ -569,6 +569,10 @@ namespace EllieBot.Migrations.PostgreSql NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + b.Property<string>("BannerUrl") + .HasColumnType("text") + .HasColumnName("bannerurl"); + b.Property<DateTime?>("DateAdded") .HasColumnType("timestamp without time zone") .HasColumnName("dateadded"); diff --git a/src/EllieBot/Migrations/Sqlite/20250310143023_club-banners.sql b/src/EllieBot/Migrations/Sqlite/20250310143023_club-banners.sql new file mode 100644 index 0000000..1f30250 --- /dev/null +++ b/src/EllieBot/Migrations/Sqlite/20250310143023_club-banners.sql @@ -0,0 +1,7 @@ +BEGIN TRANSACTION; +ALTER TABLE "Clubs" ADD "BannerUrl" TEXT NULL; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20250310143023_club-banners', '9.0.1'); + +COMMIT; diff --git a/src/EllieBot/Migrations/Sqlite/20250310101142_init.Designer.cs b/src/EllieBot/Migrations/Sqlite/20250310143048_init.Designer.cs similarity index 99% rename from src/EllieBot/Migrations/Sqlite/20250310101142_init.Designer.cs rename to src/EllieBot/Migrations/Sqlite/20250310143048_init.Designer.cs index 962bbd2..9b21005 100644 --- a/src/EllieBot/Migrations/Sqlite/20250310101142_init.Designer.cs +++ b/src/EllieBot/Migrations/Sqlite/20250310143048_init.Designer.cs @@ -11,7 +11,7 @@ using EllieBot.Db; namespace EllieBot.Migrations.Sqlite { [DbContext(typeof(SqliteContext))] - [Migration("20250310101142_init")] + [Migration("20250310143048_init")] partial class init { /// <inheritdoc /> @@ -428,6 +428,9 @@ namespace EllieBot.Migrations.Sqlite .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property<string>("BannerUrl") + .HasColumnType("TEXT"); + b.Property<DateTime?>("DateAdded") .HasColumnType("TEXT"); diff --git a/src/EllieBot/Migrations/Sqlite/20250310101142_init.cs b/src/EllieBot/Migrations/Sqlite/20250310143048_init.cs similarity index 99% rename from src/EllieBot/Migrations/Sqlite/20250310101142_init.cs rename to src/EllieBot/Migrations/Sqlite/20250310143048_init.cs index 3ac77bd..c869235 100644 --- a/src/EllieBot/Migrations/Sqlite/20250310101142_init.cs +++ b/src/EllieBot/Migrations/Sqlite/20250310143048_init.cs @@ -1603,6 +1603,7 @@ namespace EllieBot.Migrations.Sqlite Name = table.Column<string>(type: "TEXT", maxLength: 20, nullable: true), Description = table.Column<string>(type: "TEXT", nullable: true), ImageUrl = table.Column<string>(type: "TEXT", nullable: true), + BannerUrl = table.Column<string>(type: "TEXT", nullable: true), Xp = table.Column<int>(type: "INTEGER", nullable: false), OwnerId = table.Column<int>(type: "INTEGER", nullable: true), DateAdded = table.Column<DateTime>(type: "TEXT", nullable: true) diff --git a/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs b/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs index fa6e48c..9814513 100644 --- a/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs +++ b/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs @@ -425,6 +425,9 @@ namespace EllieBot.Migrations.Sqlite .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property<string>("BannerUrl") + .HasColumnType("TEXT"); + b.Property<DateTime?>("DateAdded") .HasColumnType("TEXT"); diff --git a/src/EllieBot/Modules/Utility/UserRole/UserRoleCommands.cs b/src/EllieBot/Modules/Utility/UserRole/UserRoleCommands.cs index 1952001..c838429 100644 --- a/src/EllieBot/Modules/Utility/UserRole/UserRoleCommands.cs +++ b/src/EllieBot/Modules/Utility/UserRole/UserRoleCommands.cs @@ -1,6 +1,7 @@ -using SixLabors.ImageSharp.PixelFormats; +using EllieBot.Modules.Utility.UserRole; +using SixLabors.ImageSharp.PixelFormats; -namespace EllieBot.Modules.Utility.UserRole; +namespace EllieBot.Modules.Utility; public partial class Utility { @@ -27,6 +28,14 @@ public partial class Utility return; } + var botUser = ((SocketGuild)ctx.Guild).CurrentUser; + + if (botUser.GetRoles().Max(x => x.Position) <= role.Position) + { + await Response().Error(strs.hierarchy).SendAsync(); + return; + } + var success = await _urs.AddRoleAsync(ctx.Guild.Id, user.Id, role.Id); if (!success) diff --git a/src/EllieBot/Modules/Xp/Club/Club.cs b/src/EllieBot/Modules/Xp/Club/Club.cs index 6ab0fa4..9a42d9e 100644 --- a/src/EllieBot/Modules/Xp/Club/Club.cs +++ b/src/EllieBot/Modules/Xp/Club/Club.cs @@ -29,13 +29,13 @@ public partial class Xp else { await Response() - .Confirm( - strs.club_transfered( - Format.Bold(club.Name), - Format.Bold(newOwner.ToString()) - ) - ) - .SendAsync(); + .Confirm( + strs.club_transfered( + Format.Bold(club.Name), + Format.Bold(newOwner.ToString()) + ) + ) + .SendAsync(); } } @@ -108,57 +108,85 @@ public partial class Xp await Response().Error(strs.club_icon_invalid_filetype).SendAsync(); } + [Cmd] + public async Task ClubBanner([Leftover] string url = null) + { + if ((!Uri.IsWellFormedUriString(url, UriKind.Absolute) && url is not null)) + { + await Response().Error(strs.club_icon_url_format).SendAsync(); + return; + } + + var result = await _service.SetClubBannerAsync(ctx.User.Id, url); + if (result == SetClubIconResult.Success) + { + if (url is null) + await Response().Confirm(strs.club_banner_reset).SendAsync(); + else + await Response().Confirm(strs.club_banner_set).SendAsync(); + } + else if (result == SetClubIconResult.NotOwner) + await Response().Error(strs.club_owner_only).SendAsync(); + else if (result == SetClubIconResult.TooLarge) + await Response().Error(strs.club_icon_too_large).SendAsync(); + else if (result == SetClubIconResult.InvalidFileType) + await Response().Error(strs.club_icon_invalid_filetype).SendAsync(); + } + private async Task InternalClubInfoAsync(ClubInfo club) { var lvl = new LevelStats(club.Xp); var allUsers = club.Members.OrderByDescending(x => - { - var l = new LevelStats(x.TotalXp).Level; - if (club.OwnerId == x.Id) - return int.MaxValue; - if (x.IsClubAdmin) - return (int.MaxValue / 2) + l; - return l; - }) - .ToList(); + { + var l = new LevelStats(x.TotalXp).Level; + if (club.OwnerId == x.Id) + return int.MaxValue; + if (x.IsClubAdmin) + return (int.MaxValue / 2) + l; + return l; + }) + .ToList(); var rank = await _service.GetClubRankAsync(club.Id); await Response() - .Paginated() - .Items(allUsers) - .PageSize(10) - .Page((users, _) => - { - var embed = CreateEmbed() - .WithOkColor() - .WithTitle($"{club}") - .WithDescription(GetText(strs.level_x(lvl.Level + $" ({club.Xp} xp)"))) - .AddField(GetText(strs.desc), - string.IsNullOrWhiteSpace(club.Description) ? "-" : club.Description) - .AddField(GetText(strs.rank), $"#{rank}", true) - .AddField(GetText(strs.owner), club.Owner.ToString(), true) - // .AddField(GetText(strs.level_req), club.MinimumLevelReq.ToString(), true) - .AddField(GetText(strs.members), - string.Join("\n", - users - .Select(x => - { - var l = new LevelStats(x.TotalXp); - var lvlStr = Format.Bold($" ⟪{l.Level}⟫"); - if (club.OwnerId == x.Id) - return x + "🌟" + lvlStr; - if (x.IsClubAdmin) - return x + "⭐" + lvlStr; - return x + lvlStr; - }))); + .Paginated() + .Items(allUsers) + .PageSize(10) + .Page((users, _) => + { + var embed = CreateEmbed() + .WithOkColor() + .WithTitle($"{club}") + .WithDescription(GetText(strs.level_x(lvl.Level + $" ({club.Xp} xp)"))) + .AddField(GetText(strs.desc), + string.IsNullOrWhiteSpace(club.Description) ? "-" : club.Description) + .AddField(GetText(strs.rank), $"#{rank}", true) + .AddField(GetText(strs.owner), club.Owner.ToString(), true) + // .AddField(GetText(strs.level_req), club.MinimumLevelReq.ToString(), true) + .AddField(GetText(strs.members), + string.Join("\n", + users + .Select(x => + { + var l = new LevelStats(x.TotalXp); + var lvlStr = Format.Bold($" ⟪{l.Level}⟫"); + if (club.OwnerId == x.Id) + return x + "🌟" + lvlStr; + if (x.IsClubAdmin) + return x + "⭐" + lvlStr; + return x + lvlStr; + }))); - if (Uri.IsWellFormedUriString(club.ImageUrl, UriKind.Absolute)) - return embed.WithThumbnailUrl(club.ImageUrl); + if (Uri.IsWellFormedUriString(club.ImageUrl, UriKind.Absolute)) + embed.WithThumbnailUrl(club.ImageUrl); - return embed; - }) - .SendAsync(); + if (Uri.IsWellFormedUriString(club.BannerUrl, UriKind.Absolute)) + embed.WithImageUrl(club.BannerUrl); + + return embed; + }) + .SendAsync(); } [Cmd] @@ -208,20 +236,20 @@ public partial class Xp var bans = club.Bans.Select(x => x.User).ToArray(); return Response() - .Paginated() - .Items(bans) - .PageSize(10) - .CurrentPage(page) - .Page((items, _) => - { - var toShow = string.Join("\n", items.Select(x => x.ToString())); + .Paginated() + .Items(bans) + .PageSize(10) + .CurrentPage(page) + .Page((items, _) => + { + var toShow = string.Join("\n", items.Select(x => x.ToString())); - return CreateEmbed() - .WithTitle(GetText(strs.club_bans_for(club.ToString()))) - .WithDescription(toShow) - .WithOkColor(); - }) - .SendAsync(); + return CreateEmbed() + .WithTitle(GetText(strs.club_bans_for(club.ToString()))) + .WithDescription(toShow) + .WithOkColor(); + }) + .SendAsync(); } [Cmd] @@ -237,20 +265,20 @@ public partial class Xp var apps = club.Applicants.Select(x => x.User).ToArray(); return Response() - .Paginated() - .Items(apps) - .PageSize(10) - .CurrentPage(page) - .Page((items, _) => - { - var toShow = string.Join("\n", items.Select(x => x.ToString())); + .Paginated() + .Items(apps) + .PageSize(10) + .CurrentPage(page) + .Page((items, _) => + { + var toShow = string.Join("\n", items.Select(x => x.ToString())); - return CreateEmbed() - .WithTitle(GetText(strs.club_apps_for(club.ToString()))) - .WithDescription(toShow) - .WithOkColor(); - }) - .SendAsync(); + return CreateEmbed() + .WithTitle(GetText(strs.club_apps_for(club.ToString()))) + .WithDescription(toShow) + .WithOkColor(); + }) + .SendAsync(); } [Cmd] @@ -338,9 +366,9 @@ public partial class Xp if (result == ClubKickResult.Success) { return Response() - .Confirm(strs.club_user_kick(Format.Bold(userName), - Format.Bold(club.ToString()))) - .SendAsync(); + .Confirm(strs.club_user_kick(Format.Bold(userName), + Format.Bold(club.ToString()))) + .SendAsync(); } if (result == ClubKickResult.Hierarchy) @@ -365,9 +393,9 @@ public partial class Xp if (result == ClubBanResult.Success) { return Response() - .Confirm(strs.club_user_banned(Format.Bold(userName), - Format.Bold(club.ToString()))) - .SendAsync(); + .Confirm(strs.club_user_banned(Format.Bold(userName), + Format.Bold(club.ToString()))) + .SendAsync(); } if (result == ClubBanResult.Unbannable) @@ -393,9 +421,9 @@ public partial class Xp if (result == ClubUnbanResult.Success) { return Response() - .Confirm(strs.club_user_unbanned(Format.Bold(userName), - Format.Bold(club.ToString()))) - .SendAsync(); + .Confirm(strs.club_user_unbanned(Format.Bold(userName), + Format.Bold(club.ToString()))) + .SendAsync(); } if (result == ClubUnbanResult.WrongUser) @@ -415,11 +443,11 @@ public partial class Xp ? "-" : desc; - var eb = _sender.CreateEmbed() - .WithAuthor(ctx.User) - .WithTitle(GetText(strs.club_desc_update)) - .WithOkColor() - .WithDescription(desc); + var eb = CreateEmbed() + .WithAuthor(ctx.User) + .WithTitle(GetText(strs.club_desc_update)) + .WithOkColor() + .WithDescription(desc); await Response().Embed(eb).SendAsync(); } diff --git a/src/EllieBot/Modules/Xp/Club/ClubService.cs b/src/EllieBot/Modules/Xp/Club/ClubService.cs index 60c4505..c9cd77e 100644 --- a/src/EllieBot/Modules/Xp/Club/ClubService.cs +++ b/src/EllieBot/Modules/Xp/Club/ClubService.cs @@ -120,6 +120,26 @@ public class ClubService : IEService, IClubService return SetClubIconResult.Success; } + /// <summary> + /// Sets club banner url + /// </summary> + /// <param name="ownerUserId">User ID of the club owner</param> + /// <param name="url">Banner URL to set</param> + /// <returns>Result of the operation</returns> + public async Task<SetClubIconResult> SetClubBannerAsync(ulong ownerUserId, string? url) + { + await using var uow = _db.GetDbContext(); + var club = uow.Set<ClubInfo>().GetByOwner(ownerUserId); + + if (club is null) + return SetClubIconResult.NotOwner; + + club.BannerUrl = url; + await uow.SaveChangesAsync(); + + return SetClubIconResult.Success; + } + public bool GetClubByName(string clubName, out ClubInfo club) { using var uow = _db.GetDbContext(); diff --git a/src/EllieBot/Modules/Xp/Club/IClubService.cs b/src/EllieBot/Modules/Xp/Club/IClubService.cs index ea23436..8764a6e 100644 --- a/src/EllieBot/Modules/Xp/Club/IClubService.cs +++ b/src/EllieBot/Modules/Xp/Club/IClubService.cs @@ -10,6 +10,7 @@ public interface IClubService Task<ToggleAdminResult> ToggleAdminAsync(IUser owner, IUser toAdmin); ClubInfo? GetClubByMember(IUser user); Task<SetClubIconResult> SetClubIconAsync(ulong ownerUserId, string? url); + Task<SetClubIconResult> SetClubBannerAsync(ulong ownerUserId, string? url); bool GetClubByName(string clubName, out ClubInfo club); ClubApplyResult ApplyToClub(IUser user, ClubInfo club); ClubAcceptResult AcceptApplication(ulong clubOwnerUserId, string userName, out DiscordUser? discordUser); diff --git a/src/EllieBot/strings/commands/commands.en-US.yml b/src/EllieBot/strings/commands/commands.en-US.yml index d8ac450..88e793c 100644 --- a/src/EllieBot/strings/commands/commands.en-US.yml +++ b/src/EllieBot/strings/commands/commands.en-US.yml @@ -3694,6 +3694,17 @@ clubicon: params: - url: desc: "The URL of an image file to use as the club icon." +clubbanner: + desc: |- + Sets an image as a club banner. + The banner will be displayed when club information is shown. + ex: + - 'https://i.imgur.com/example.png' + - '' + params: + - { } + - url: + desc: "URL to the image to set as a club banner." clubapps: desc: Shows the list of users who have applied to your club. Paginated. You must be club owner to use this command. ex: @@ -5034,19 +5045,19 @@ userrolecolor: color: desc: 'The new color for the role in hex format.' userroleicon: - desc: |- - Changes the icon of your assigned role. - ex: - - '@Role :thumbsup:' - params: - - role: - desc: 'The assigned role to change the icon of.' - icon: - desc: 'The new icon for the role.' - - role: - desc: 'The assigned role to change the icon of.' - emote: - desc: 'The server emoji for the role.' + desc: |- + Changes the icon of your assigned role. + ex: + - '@Role :thumbsup:' + params: + - role: + desc: 'The assigned role to change the icon of.' + imageUrl: + desc: 'The image url to be used as a new icon for the role.' + - role: + desc: 'The assigned role to change the icon of.' + serverEmoji: + desc: 'The server emoji to be used as a new icon for the role.' userrolename: desc: |- Changes the name of your assigned role. diff --git a/src/EllieBot/strings/responses/responses.en-US.json b/src/EllieBot/strings/responses/responses.en-US.json index b54e7ec..2e90d69 100644 --- a/src/EllieBot/strings/responses/responses.en-US.json +++ b/src/EllieBot/strings/responses/responses.en-US.json @@ -887,6 +887,8 @@ "club_icon_invalid_filetype": "Specified image has an invalid filetype. Make sure you're specifying a direct image url.", "club_icon_url_format": "You must specify an absolute image url.", "club_icon_set": "New club icon set.", + "club_banner_set": "New club banner set.", + "club_banner_reset": "Club banner has been reset.", "club_bans_for": "Bans for {0} club", "club_apps_for": "Applicants for {0} club", "club_leaderboard": "Club leaderboard - page {0}", @@ -1201,6 +1203,6 @@ "userrole_icon_success": "The icon for {0} has been saved.", "userrole_icon_fail": "Failed to set the role icon.", "userrole_icon_invalid": "The role icon cannot be empty.", - "userrole_hierarchy_error": "You can't assign or modify roles that are higher than or equal to your highest role.", + "userrole_hierarchy_error": "You can't assign or modify roles that are higher than or equal to your, or bots highest role.", "userrole_role_not_exists": "That role doesn't exist." }