diff --git a/src/EllieBot/Migrations/PostgreSql/20250323021916_linkfixer.sql b/src/EllieBot/Migrations/PostgreSql/20250323021916_linkfixer.sql new file mode 100644 index 0000000..01bd6b3 --- /dev/null +++ b/src/EllieBot/Migrations/PostgreSql/20250323021916_linkfixer.sql @@ -0,0 +1,16 @@ +START TRANSACTION; +CREATE TABLE linkfix ( + id integer GENERATED BY DEFAULT AS IDENTITY, + guildid numeric(20,0) NOT NULL, + olddomain text NOT NULL, + newdomain text NOT NULL, + CONSTRAINT pk_linkfix PRIMARY KEY (id) +); + +CREATE UNIQUE INDEX ix_linkfix_guildid_olddomain ON linkfix (guildid, olddomain); + +INSERT INTO "__EFMigrationsHistory" (migrationid, productversion) +VALUES ('20250323021916_linkfixer', '9.0.1'); + +COMMIT; + diff --git a/src/EllieBot/Migrations/PostgreSql/20250319010930_init.Designer.cs b/src/EllieBot/Migrations/PostgreSql/20250323022235_init.Designer.cs similarity index 99% rename from src/EllieBot/Migrations/PostgreSql/20250319010930_init.Designer.cs rename to src/EllieBot/Migrations/PostgreSql/20250323022235_init.Designer.cs index 6b6214f..45a075b 100644 --- a/src/EllieBot/Migrations/PostgreSql/20250319010930_init.Designer.cs +++ b/src/EllieBot/Migrations/PostgreSql/20250323022235_init.Designer.cs @@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace EllieBot.Migrations.PostgreSql { [DbContext(typeof(PostgreSqlContext))] - [Migration("20250319010930_init")] + [Migration("20250323022235_init")] partial class init { /// <inheritdoc /> @@ -1541,6 +1541,39 @@ namespace EllieBot.Migrations.PostgreSql b.ToTable("imageonlychannels", (string)null); }); + modelBuilder.Entity("EllieBot.Db.Models.LinkFix", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<decimal>("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property<string>("NewDomain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("newdomain"); + + b.Property<string>("OldDomain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("olddomain"); + + b.HasKey("Id") + .HasName("pk_linkfix"); + + b.HasIndex("GuildId", "OldDomain") + .IsUnique() + .HasDatabaseName("ix_linkfix_guildid_olddomain"); + + b.ToTable("linkfix", (string)null); + }); + modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b => { b.Property<int>("Id") diff --git a/src/EllieBot/Migrations/PostgreSql/20250319010930_init.cs b/src/EllieBot/Migrations/PostgreSql/20250323022235_init.cs similarity index 99% rename from src/EllieBot/Migrations/PostgreSql/20250319010930_init.cs rename to src/EllieBot/Migrations/PostgreSql/20250323022235_init.cs index 6cfd21e..8ec83bd 100644 --- a/src/EllieBot/Migrations/PostgreSql/20250319010930_init.cs +++ b/src/EllieBot/Migrations/PostgreSql/20250323022235_init.cs @@ -550,6 +550,21 @@ namespace EllieBot.Migrations.PostgreSql table.PrimaryKey("pk_imageonlychannels", x => x.id); }); + migrationBuilder.CreateTable( + name: "linkfix", + columns: table => new + { + id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column<decimal>(type: "numeric(20,0)", nullable: false), + olddomain = table.Column<string>(type: "text", nullable: false), + newdomain = table.Column<string>(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_linkfix", x => x.id); + }); + migrationBuilder.CreateTable( name: "livechannelconfig", columns: table => new @@ -1999,6 +2014,12 @@ namespace EllieBot.Migrations.PostgreSql column: "channelid", unique: true); + migrationBuilder.CreateIndex( + name: "ix_linkfix_guildid_olddomain", + table: "linkfix", + columns: new[] { "guildid", "olddomain" }, + unique: true); + migrationBuilder.CreateIndex( name: "ix_livechannelconfig_guildid", table: "livechannelconfig", @@ -2501,6 +2522,9 @@ namespace EllieBot.Migrations.PostgreSql migrationBuilder.DropTable( name: "imageonlychannels"); + migrationBuilder.DropTable( + name: "linkfix"); + migrationBuilder.DropTable( name: "livechannelconfig"); diff --git a/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs index 0c236ba..f8ed231 100644 --- a/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs +++ b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs @@ -1538,6 +1538,39 @@ namespace EllieBot.Migrations.PostgreSql b.ToTable("imageonlychannels", (string)null); }); + modelBuilder.Entity("EllieBot.Db.Models.LinkFix", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<decimal>("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property<string>("NewDomain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("newdomain"); + + b.Property<string>("OldDomain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("olddomain"); + + b.HasKey("Id") + .HasName("pk_linkfix"); + + b.HasIndex("GuildId", "OldDomain") + .IsUnique() + .HasDatabaseName("ix_linkfix_guildid_olddomain"); + + b.ToTable("linkfix", (string)null); + }); + modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b => { b.Property<int>("Id") diff --git a/src/EllieBot/Migrations/Sqlite/20250323021857_linkfixer.sql b/src/EllieBot/Migrations/Sqlite/20250323021857_linkfixer.sql new file mode 100644 index 0000000..deb0149 --- /dev/null +++ b/src/EllieBot/Migrations/Sqlite/20250323021857_linkfixer.sql @@ -0,0 +1,15 @@ +BEGIN TRANSACTION; +CREATE TABLE "LinkFix" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_LinkFix" PRIMARY KEY AUTOINCREMENT, + "GuildId" INTEGER NOT NULL, + "OldDomain" TEXT NOT NULL, + "NewDomain" TEXT NOT NULL +); + +CREATE UNIQUE INDEX "IX_LinkFix_GuildId_OldDomain" ON "LinkFix" ("GuildId", "OldDomain"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20250323021857_linkfixer', '9.0.1'); + +COMMIT; + diff --git a/src/EllieBot/Migrations/Sqlite/20250319010920_init.Designer.cs b/src/EllieBot/Migrations/Sqlite/20250323022218_init.Designer.cs similarity index 99% rename from src/EllieBot/Migrations/Sqlite/20250319010920_init.Designer.cs rename to src/EllieBot/Migrations/Sqlite/20250323022218_init.Designer.cs index fc2c559..799caba 100644 --- a/src/EllieBot/Migrations/Sqlite/20250319010920_init.Designer.cs +++ b/src/EllieBot/Migrations/Sqlite/20250323022218_init.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace EllieBot.Migrations.Sqlite { [DbContext(typeof(SqliteContext))] - [Migration("20250319010920_init")] + [Migration("20250323022218_init")] partial class init { /// <inheritdoc /> @@ -1151,6 +1151,31 @@ namespace EllieBot.Migrations.Sqlite b.ToTable("ImageOnlyChannels"); }); + modelBuilder.Entity("EllieBot.Db.Models.LinkFix", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<ulong>("GuildId") + .HasColumnType("INTEGER"); + + b.Property<string>("NewDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("OldDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "OldDomain") + .IsUnique(); + + b.ToTable("LinkFix"); + }); + modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b => { b.Property<int>("Id") diff --git a/src/EllieBot/Migrations/Sqlite/20250319010920_init.cs b/src/EllieBot/Migrations/Sqlite/20250323022218_init.cs similarity index 99% rename from src/EllieBot/Migrations/Sqlite/20250319010920_init.cs rename to src/EllieBot/Migrations/Sqlite/20250323022218_init.cs index 7089818..a3e5477 100644 --- a/src/EllieBot/Migrations/Sqlite/20250319010920_init.cs +++ b/src/EllieBot/Migrations/Sqlite/20250323022218_init.cs @@ -550,6 +550,21 @@ namespace EllieBot.Migrations.Sqlite table.PrimaryKey("PK_ImageOnlyChannels", x => x.Id); }); + migrationBuilder.CreateTable( + name: "LinkFix", + columns: table => new + { + Id = table.Column<int>(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + GuildId = table.Column<ulong>(type: "INTEGER", nullable: false), + OldDomain = table.Column<string>(type: "TEXT", nullable: false), + NewDomain = table.Column<string>(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LinkFix", x => x.Id); + }); + migrationBuilder.CreateTable( name: "LiveChannelConfig", columns: table => new @@ -2001,6 +2016,12 @@ namespace EllieBot.Migrations.Sqlite column: "ChannelId", unique: true); + migrationBuilder.CreateIndex( + name: "IX_LinkFix_GuildId_OldDomain", + table: "LinkFix", + columns: new[] { "GuildId", "OldDomain" }, + unique: true); + migrationBuilder.CreateIndex( name: "IX_LiveChannelConfig_GuildId", table: "LiveChannelConfig", @@ -2503,6 +2524,9 @@ namespace EllieBot.Migrations.Sqlite migrationBuilder.DropTable( name: "ImageOnlyChannels"); + migrationBuilder.DropTable( + name: "LinkFix"); + migrationBuilder.DropTable( name: "LiveChannelConfig"); diff --git a/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs b/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs index 2c146f0..c70ba74 100644 --- a/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs +++ b/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs @@ -1148,6 +1148,31 @@ namespace EllieBot.Migrations.Sqlite b.ToTable("ImageOnlyChannels"); }); + modelBuilder.Entity("EllieBot.Db.Models.LinkFix", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<ulong>("GuildId") + .HasColumnType("INTEGER"); + + b.Property<string>("NewDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("OldDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "OldDomain") + .IsUnique(); + + b.ToTable("LinkFix"); + }); + modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b => { b.Property<int>("Id") diff --git a/src/EllieBot/Modules/Utility/LinkFixer/LinkFix.cs b/src/EllieBot/Modules/Utility/LinkFixer/LinkFix.cs new file mode 100644 index 0000000..b1dd35a --- /dev/null +++ b/src/EllieBot/Modules/Utility/LinkFixer/LinkFix.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace EllieBot.Db.Models; + +/// <summary> +/// Represents a link fix configuration for a guild +/// </summary> +public class LinkFix +{ + [Key] + public int Id { get; set; } + + /// <summary> + /// ID of the guild this link fix belongs to + /// </summary> + public ulong GuildId { get; set; } + + /// <summary> + /// The domain to be replaced + /// </summary> + public string OldDomain { get; set; } = null!; + + /// <summary> + /// The domain to replace with + /// </summary> + public string NewDomain { get; set; } = null!; +} + +/// <summary> +/// Entity configuration for <see cref="LinkFix"/> +/// </summary> +public class LinkFixConfiguration : IEntityTypeConfiguration<LinkFix> +{ + public void Configure(EntityTypeBuilder<LinkFix> builder) + { + builder.HasIndex(x => new { x.GuildId, x.OldDomain }).IsUnique(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/LinkFixer/LinkFixerCommands.cs b/src/EllieBot/Modules/Utility/LinkFixer/LinkFixerCommands.cs new file mode 100644 index 0000000..11c57af --- /dev/null +++ b/src/EllieBot/Modules/Utility/LinkFixer/LinkFixerCommands.cs @@ -0,0 +1,100 @@ +using DryIoc.ImTools; +using EllieBot.Modules.Utility.LinkFixer; + +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Group] + public class LinkFixerCommands : EllieModule<LinkFixerService> + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task LinkFix(string oldDomain, string? newDomain = null) + { + if (string.IsNullOrWhiteSpace(newDomain)) + { + var rmSuccess = await _service.RemoveLinkFixAsync(ctx.Guild.Id, oldDomain); + + if (rmSuccess) + await Response().Confirm(strs.linkfix_removed(Format.Bold(oldDomain))).SendAsync(); + else + await Response().Error(strs.linkfix_not_found(Format.Bold(oldDomain))).SendAsync(); + + return; + } + + oldDomain = CleanDomain(oldDomain); + newDomain = newDomain.Trim(); + + if (string.IsNullOrWhiteSpace(oldDomain) || string.IsNullOrWhiteSpace(newDomain)) + { + await Response().Error(strs.linkfix_invalid_domains).SendAsync(); + return; + } + + var success = await _service.AddLinkFixAsync(ctx.Guild.Id, oldDomain, newDomain); + if (success) + await Response().Confirm(strs.linkfix_added(Format.Bold(oldDomain), Format.Bold(newDomain))).SendAsync(); + else + await Response().Error(strs.linkfix_already_exists(Format.Bold(oldDomain))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task LinkFixList() + { + var linkFixes = _service.GetLinkFixes(ctx.Guild.Id); + if (linkFixes.Count == 0) + { + await Response().Confirm(strs.linkfix_list_none).SendAsync(); + return; + } + + var items = linkFixes.Select(x => $"{Format.Bold(x.Key)} -> {Format.Bold(x.Value)}").ToList(); + + await Response() + .Paginated() + .Items(items) + .PageSize(10) + .Page((items, _) => + { + var eb = CreateEmbed() + .WithTitle(GetText(strs.linkfix_list_title)) + .WithDescription(string.Join('\n', items)) + .WithOkColor(); + + return eb; + }) + .SendAsync(); + } + + /// <summary> + /// Removes protocol and www. from a domain + /// </summary> + /// <param name="domain">The domain to clean</param> + private static string CleanDomain(string domain) + { + // Remove protocol if present + if (domain.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + domain = domain[7..]; + else if (domain.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + domain = domain[8..]; + + // Remove www. if present + if (domain.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) + domain = domain[4..]; + + // Remove any path or query string + var pathIndex = domain.IndexOf('/'); + if (pathIndex > 0) + domain = domain[..pathIndex]; + + if (domain.Split('.').Length != 2) + return string.Empty; + + return domain.ToLowerInvariant(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/LinkFixer/LinkFixerService.cs b/src/EllieBot/Modules/Utility/LinkFixer/LinkFixerService.cs new file mode 100644 index 0000000..cf8ba82 --- /dev/null +++ b/src/EllieBot/Modules/Utility/LinkFixer/LinkFixerService.cs @@ -0,0 +1,133 @@ +using System.Text.RegularExpressions; +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility.LinkFixer; + +/// <summary> +/// Service for managing link fixing functionality +/// </summary> +public partial class LinkFixerService(DbService db) : IReadyExecutor, IExecNoCommand, IEService +{ + private readonly ConcurrentDictionary<ulong, ConcurrentDictionary<string, string>> _guildLinkFixes = new(); + + public async Task OnReadyAsync() + { + await using var uow = db.GetDbContext(); + var linkFixes = await uow.GetTable<LinkFix>() + .AsNoTracking() + .ToListAsyncLinqToDB(); + + foreach (var fix in linkFixes) + { + var guildDict = _guildLinkFixes.GetOrAdd(fix.GuildId, _ => new(StringComparer.InvariantCultureIgnoreCase)); + guildDict.TryAdd(fix.OldDomain.ToLowerInvariant(), fix.NewDomain); + } + } + + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + if(guild is null) + return; + + var guildId = guild.Id; + if (!_guildLinkFixes.TryGetValue(guildId, out var guildDict)) + return; + + var content = msg.Content; + if (string.IsNullOrWhiteSpace(content)) + return; + + var words = content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + foreach (var word in words) + { + var match = UrlRegex().Match(word); + if (!match.Success) + continue; + + var domain = match.Groups["domain"].Value; + if (string.IsNullOrWhiteSpace(domain)) + continue; + + if(!guildDict.TryGetValue(domain, out var newDomain)) + continue; + + var newUrl = match.Groups["prefix"].Value + newDomain + match.Groups["suffix"].Value; + await msg.ReplyAsync(newUrl, allowedMentions: AllowedMentions.None); + } + } + + [GeneratedRegex("(?<prefix>https?://(?:www\\.)?)(?<domain>[^/]+)(?<suffix>.*)")] + private partial Regex UrlRegex(); + + /// <summary> + /// Adds a new link fix for a guild + /// </summary> + /// <param name="guildId">ID of the guild</param> + /// <param name="oldDomain">Domain to be replaced</param> + /// <param name="newDomain">Domain to replace with</param> + /// <returns>True if successfully added, false if already exists</returns> + public async Task<bool> AddLinkFixAsync(ulong guildId, string oldDomain, string newDomain) + { + oldDomain = oldDomain.ToLowerInvariant(); + + var guildDict = _guildLinkFixes.GetOrAdd(guildId, _ => new ConcurrentDictionary<string, string>()); + guildDict[oldDomain] = newDomain; + + await using var uow = db.GetDbContext(); + await uow.GetTable<LinkFix>() + .InsertOrUpdateAsync(() => new LinkFix + { + GuildId = guildId, + OldDomain = oldDomain, + NewDomain = newDomain + }, + old => new LinkFix + { + NewDomain = newDomain + }, + () => new LinkFix + { + GuildId = guildId, + OldDomain = oldDomain, + }); + + return true; + } + + /// <summary> + /// Removes a link fix from a guild + /// </summary> + /// <param name="guildId">ID of the guild</param> + /// <param name="oldDomain">Domain to remove from fixes</param> + /// <returns>True if successfully removed, false if not found</returns> + public async Task<bool> RemoveLinkFixAsync(ulong guildId, string oldDomain) + { + oldDomain = oldDomain.ToLowerInvariant(); + + if (!_guildLinkFixes.TryGetValue(guildId, out var guildDict) || !guildDict.TryRemove(oldDomain, out _)) + return false; + + await using var uow = db.GetDbContext(); + await uow.GetTable<LinkFix>() + .DeleteAsync(lf => lf.GuildId == guildId && lf.OldDomain == oldDomain); + + return true; + } + + /// <summary> + /// Gets all link fixes for a guild + /// </summary> + /// <param name="guildId">ID of the guild</param> + /// <returns>Dictionary of old domains to new domains</returns> + public IReadOnlyDictionary<string, string> GetLinkFixes(ulong guildId) + { + if (_guildLinkFixes.TryGetValue(guildId, out var guildDict)) + return guildDict; + + return new Dictionary<string, string>(); + } +} \ No newline at end of file diff --git a/src/EllieBot/strings/aliases.yml b/src/EllieBot/strings/aliases.yml index 8d02258..112c63c 100644 --- a/src/EllieBot/strings/aliases.yml +++ b/src/EllieBot/strings/aliases.yml @@ -1644,4 +1644,10 @@ livechlist: livechremove: - livechremove - lchd - - lchrm \ No newline at end of file + - lchrm +linkfix: + - linkfix + - lfix +linkfixlist: + - linkfixlist + - lfixlist \ No newline at end of file diff --git a/src/EllieBot/strings/commands/commands.en-US.yml b/src/EllieBot/strings/commands/commands.en-US.yml index ddf0a6c..9eb73a1 100644 --- a/src/EllieBot/strings/commands/commands.en-US.yml +++ b/src/EllieBot/strings/commands/commands.en-US.yml @@ -5148,4 +5148,24 @@ livechremove: - '#general' params: - channel: - desc: "The channel to remove from live channels." \ No newline at end of file + desc: "The channel to remove from live channels." +linkfix: + desc: |- + Configures automatic link fixing from one site to another. + When a user posts a link containing the old domain, the bot will automatically fix it to use the new domain. + Provide no second domain to disable link fixing. + ex: + - 'twitter.com vxtwitter.com' + - 'x.com' + params: + - oldDomain: + desc: "The domain to be replaced." + newDomain: + desc: "The domain to replace with." +linkfixlist: + desc: |- + Lists all configured link fixes for the server. + ex: + - '' + params: + - { } \ No newline at end of file diff --git a/src/EllieBot/strings/responses/responses.en-US.json b/src/EllieBot/strings/responses/responses.en-US.json index 6ad7a04..8f9b283 100644 --- a/src/EllieBot/strings/responses/responses.en-US.json +++ b/src/EllieBot/strings/responses/responses.en-US.json @@ -1230,5 +1230,13 @@ "livechannel_list_empty": "No live channels configured for this server.", "livechannel_please_wait": "Please allow up to 10 minutes for the changes to take effect", "template": "Template", - "preview": "Preview" + "preview": "Preview", + "linkfix_invalid_domains": "Both old and new domains must be valid.", + "linkfix_invalid_domain": "The domain must be valid.", + "linkfix_added": "Links from {0} will now be fixed to {1}.", + "linkfix_already_exists": "A link fix for {0} already exists.", + "linkfix_list_none": "No link fixes have been configured for this server.", + "linkfix_list_title": "Link Fixes", + "linkfix_removed": "Link fix for {0} has been removed.", + "linkfix_not_found": "No link fix found for {0}." } \ No newline at end of file