From 33221fee2b25d790aa08855df6363c865ad8fb49 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sun, 13 Apr 2025 08:19:42 -0600 Subject: [PATCH] Fix Genre/Tag with spaces getting normalized (#3731) --- API/Controllers/AdminController.cs | 18 ++------- API/DTOs/Progress/UpdateUserProgressDto.cs | 11 ------ API/EmailTemplates/KavitaPlusDebug.html | 20 ++++++++++ API/Extensions/StringExtensions.cs | 6 +-- API/Helpers/Builders/GenreBuilder.cs | 6 +-- API/Helpers/Builders/TagBuilder.cs | 4 +- API/Helpers/GenreHelper.cs | 9 ++++- API/Helpers/TagHelper.cs | 22 ++++++----- API/Services/EmailService.cs | 44 ++++++++++++++++++++-- UI/Web/package-lock.json | 15 ++++++-- 10 files changed, 104 insertions(+), 51 deletions(-) delete mode 100644 API/DTOs/Progress/UpdateUserProgressDto.cs create mode 100644 API/EmailTemplates/KavitaPlusDebug.html diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index 78918704d..4f8edd511 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.ManualMigrations; using API.DTOs; @@ -16,12 +17,10 @@ namespace API.Controllers; public class AdminController : BaseApiController { private readonly UserManager _userManager; - private readonly IUnitOfWork _unitOfWork; - public AdminController(UserManager userManager, IUnitOfWork unitOfWork) + public AdminController(UserManager userManager) { _userManager = userManager; - _unitOfWork = unitOfWork; } /// @@ -32,18 +31,7 @@ public class AdminController : BaseApiController [HttpGet("exists")] public async Task> AdminExists() { - var users = await _userManager.GetUsersInRoleAsync("Admin"); + var users = await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); return users.Count > 0; } - - /// - /// Set the progress information for a particular user - /// - /// - [Authorize("RequireAdminRole")] - [HttpPost("update-chapter-progress")] - public async Task> UpdateChapterProgress(UpdateUserProgressDto dto) - { - return Ok(await Task.FromResult(false)); - } } diff --git a/API/DTOs/Progress/UpdateUserProgressDto.cs b/API/DTOs/Progress/UpdateUserProgressDto.cs deleted file mode 100644 index 2aa77b04e..000000000 --- a/API/DTOs/Progress/UpdateUserProgressDto.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; - -namespace API.DTOs.Progress; -#nullable enable - -public class UpdateUserProgressDto -{ - public int PageNum { get; set; } - public DateTime LastModifiedUtc { get; set; } - public DateTime CreatedUtc { get; set; } -} diff --git a/API/EmailTemplates/KavitaPlusDebug.html b/API/EmailTemplates/KavitaPlusDebug.html new file mode 100644 index 000000000..e165dfb98 --- /dev/null +++ b/API/EmailTemplates/KavitaPlusDebug.html @@ -0,0 +1,20 @@ + + +

A User needs manual registration

+ + + +
+ + + + + + +
+ + diff --git a/API/Extensions/StringExtensions.cs b/API/Extensions/StringExtensions.cs index 138209e0d..28419921a 100644 --- a/API/Extensions/StringExtensions.cs +++ b/API/Extensions/StringExtensions.cs @@ -18,9 +18,9 @@ public static class StringExtensions // Remove all newline and control characters var sanitized = input - .Replace(Environment.NewLine, "") - .Replace("\n", "") - .Replace("\r", ""); + .Replace(Environment.NewLine, string.Empty) + .Replace("\n", string.Empty) + .Replace("\r", string.Empty); // Optionally remove other potentially unwanted characters sanitized = Regex.Replace(sanitized, @"[^\u0020-\u007E]", string.Empty); // Removes non-printable ASCII diff --git a/API/Helpers/Builders/GenreBuilder.cs b/API/Helpers/Builders/GenreBuilder.cs index 69e68f6c1..9b2f1590e 100644 --- a/API/Helpers/Builders/GenreBuilder.cs +++ b/API/Helpers/Builders/GenreBuilder.cs @@ -16,14 +16,14 @@ public class GenreBuilder : IEntityBuilder { Title = name.Trim().SentenceCase(), NormalizedTitle = name.ToNormalized(), - Chapters = new List(), - SeriesMetadatas = new List() + Chapters = [], + SeriesMetadatas = [] }; } public GenreBuilder WithSeriesMetadata(SeriesMetadata seriesMetadata) { - _genre.SeriesMetadatas ??= new List(); + _genre.SeriesMetadatas ??= []; _genre.SeriesMetadatas.Add(seriesMetadata); return this; } diff --git a/API/Helpers/Builders/TagBuilder.cs b/API/Helpers/Builders/TagBuilder.cs index 084171f54..623587fd1 100644 --- a/API/Helpers/Builders/TagBuilder.cs +++ b/API/Helpers/Builders/TagBuilder.cs @@ -16,8 +16,8 @@ public class TagBuilder : IEntityBuilder { Title = name.Trim().SentenceCase(), NormalizedTitle = name.ToNormalized(), - Chapters = new List(), - SeriesMetadatas = new List() + Chapters = [], + SeriesMetadatas = [] }; } diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs index 1f7ca53d6..8580178d9 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -19,7 +19,12 @@ public static class GenreHelper public static async Task UpdateChapterGenres(Chapter chapter, IEnumerable genreNames, IUnitOfWork unitOfWork) { // Normalize genre names once and store them in a hash set for quick lookups - var normalizedGenresToAdd = new HashSet(genreNames.Select(g => g.ToNormalized())); + var normalizedToOriginal = genreNames + .Select(g => new { Original = g, Normalized = g.ToNormalized() }) + .GroupBy(x => x.Normalized) + .ToDictionary(g => g.Key, g => g.First().Original); + + var normalizedGenresToAdd = new HashSet(normalizedToOriginal.Keys); // Remove genres that are no longer in the new list var genresToRemove = chapter.Genres @@ -42,7 +47,7 @@ public static class GenreHelper // Find missing genres that are not in the database var missingGenres = normalizedGenresToAdd .Where(nt => !existingGenreTitles.ContainsKey(nt)) - .Select(title => new GenreBuilder(title).Build()) + .Select(nt => new GenreBuilder(normalizedToOriginal[nt]).Build()) .ToList(); // Add missing genres to the database diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs index d05be7a32..c00d6ee8f 100644 --- a/API/Helpers/TagHelper.cs +++ b/API/Helpers/TagHelper.cs @@ -20,7 +20,13 @@ public static class TagHelper public static async Task UpdateChapterTags(Chapter chapter, IEnumerable tagNames, IUnitOfWork unitOfWork) { // Normalize tag names once and store them in a hash set for quick lookups - var normalizedTagsToAdd = new HashSet(tagNames.Select(t => t.ToNormalized())); + // Create a dictionary: normalized => original + var normalizedToOriginal = tagNames + .Select(t => new { Original = t, Normalized = t.ToNormalized() }) + .GroupBy(x => x.Normalized) // in case of duplicates + .ToDictionary(g => g.Key, g => g.First().Original); + + var normalizedTagsToAdd = new HashSet(normalizedToOriginal.Keys); var existingTagsSet = new HashSet(chapter.Tags.Select(t => t.NormalizedTitle)); var isModified = false; @@ -30,7 +36,7 @@ public static class TagHelper .Where(t => !normalizedTagsToAdd.Contains(t.NormalizedTitle)) .ToList(); - if (tagsToRemove.Any()) + if (tagsToRemove.Count != 0) { foreach (var tagToRemove in tagsToRemove) { @@ -47,7 +53,7 @@ public static class TagHelper // Find missing tags that are not already in the database var missingTags = normalizedTagsToAdd .Where(nt => !existingTagTitles.ContainsKey(nt)) - .Select(title => new TagBuilder(title).Build()) + .Select(nt => new TagBuilder(normalizedToOriginal[nt]).Build()) .ToList(); // Add missing tags to the database if any @@ -67,13 +73,11 @@ public static class TagHelper // Add the new or existing tags to the chapter foreach (var normalizedTitle in normalizedTagsToAdd) { - var tag = existingTagTitles[normalizedTitle]; + if (existingTagsSet.Contains(normalizedTitle)) continue; - if (!existingTagsSet.Contains(normalizedTitle)) - { - chapter.Tags.Add(tag); - isModified = true; - } + var tag = existingTagTitles[normalizedTitle]; + chapter.Tags.Add(tag); + isModified = true; } // Commit changes if modifications were made to the chapter's tags diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 72b63e0e5..35cfa7b04 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Net; +using System.Text; using System.Threading.Tasks; using System.Web; using API.Data; @@ -11,6 +12,7 @@ using API.DTOs.Email; using API.Entities; using API.Services.Plus; using Kavita.Common; +using Kavita.Common.EnvironmentInfo; using Kavita.Common.Extensions; using MailKit.Security; using Microsoft.AspNetCore.Http; @@ -52,6 +54,7 @@ public interface IEmailService Task SendTokenExpiredEmail(int userId, ScrobbleProvider provider); Task SendTokenExpiringSoonEmail(int userId, ScrobbleProvider provider); + Task SendKavitaPlusDebug(); } public class EmailService : IEmailService @@ -72,6 +75,7 @@ public class EmailService : IEmailService public const string TokenExpiringSoonTemplate = "TokenExpiringSoon"; public const string EmailConfirmTemplate = "EmailConfirm"; public const string EmailPasswordResetTemplate = "EmailPasswordReset"; + public const string KavitaPlusDebugTemplate = "KavitaPlusDebug"; public EmailService(ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService, IHostEnvironment environment, ILocalizationService localizationService) @@ -259,6 +263,40 @@ public class EmailService : IEmailService return true; } + /// + /// Sends information about Kavita install for Kavita+ registration + /// + /// Users in China can have issues subscribing, this flow will allow me to register their instance on their behalf + /// + public async Task SendKavitaPlusDebug() + { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!settings.IsEmailSetup()) return false; + + var placeholders = new List> + { + new ("{{InstallId}}", HashUtil.ServerToken()), + new ("{{Build}}", BuildInfo.Version.ToString()), + }; + + var emailOptions = new EmailOptionsDto() + { + Subject = UpdatePlaceHolders("Kavita+: A User needs manual registration", placeholders), + Template = KavitaPlusDebugTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(KavitaPlusDebugTemplate), placeholders), + Preheader = UpdatePlaceHolders("Kavita+: A User needs manual registration", placeholders), + ToEmails = + [ + // My kavita email + Encoding.UTF8.GetString(Convert.FromBase64String("a2F2aXRhcmVhZGVyQGdtYWlsLmNvbQ==")) + ] + }; + + await SendEmail(emailOptions); + + return true; + } + /// /// Sends an invite email to a user to setup their account /// @@ -304,10 +342,10 @@ public class EmailService : IEmailService Template = EmailPasswordResetTemplate, Body = UpdatePlaceHolders(await GetEmailBody(EmailPasswordResetTemplate), placeholders), Preheader = "Email confirmation is required for continued access. Click the button to confirm your email.", - ToEmails = new List() - { + ToEmails = + [ dto.EmailAddress - } + ] }; await SendEmail(emailOptions); diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 8d6b0ebd6..cfce8cded 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -541,6 +541,7 @@ "version": "19.2.5", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.5.tgz", "integrity": "sha512-b2cG41r6lilApXLlvja1Ra2D00dM3BxmQhoElKC1tOnpD6S3/krlH1DOnBB2I55RBn9iv4zdmPz1l8zPUSh7DQ==", + "dev": true, "dependencies": { "@babel/core": "7.26.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -568,6 +569,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -582,6 +584,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, "engines": { "node": ">= 14.16.0" }, @@ -4903,7 +4906,8 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -5350,6 +5354,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -5359,6 +5364,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8175,7 +8181,8 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true }, "node_modules/replace-in-file": { "version": "7.1.0", @@ -8396,7 +8403,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true + "dev": true }, "node_modules/sass": { "version": "1.85.0", @@ -8461,6 +8468,7 @@ "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -9085,6 +9093,7 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"