diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index fa0448ff9..689327d98 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parser/ComicParserTests.cs @@ -194,7 +194,7 @@ public class ComicParserTests [InlineData("Asterix - HS - Les 12 travaux d'Astérix", true)] [InlineData("Sillage Hors Série - Le Collectionneur - Concordance-DKFR", true)] [InlineData("laughs", false)] - [InlineData("Annual Days of Summer", false)] + [InlineData("Annual Days of Summer", true)] [InlineData("Adventure Time 2013 Annual #001 (2013)", true)] [InlineData("Adventure Time 2013_Annual_#001 (2013)", true)] [InlineData("Adventure Time 2013_-_Annual #001 (2013)", true)] @@ -202,6 +202,13 @@ public class ComicParserTests [InlineData("Mazebook 001", false)] [InlineData("X-23 One Shot (2010)", true)] [InlineData("Casus Belli v1 Hors-Série 21 - Mousquetaires et Sorcellerie", true)] + [InlineData("Batman Beyond Annual", true)] + [InlineData("Batman Beyond Bonus", true)] + [InlineData("Batman Beyond OneShot", true)] + [InlineData("Batman Beyond Specials", true)] + [InlineData("Batman Beyond Omnibus (1999)", true)] + [InlineData("Batman Beyond Omnibus", true)] + [InlineData("01 Annual Batman Beyond", true)] public void IsComicSpecialTest(string input, bool expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.IsComicSpecial(input)); diff --git a/API.Tests/Parser/DefaultParserTests.cs b/API.Tests/Parser/DefaultParserTests.cs index 2640aa6c2..7f843b552 100644 --- a/API.Tests/Parser/DefaultParserTests.cs +++ b/API.Tests/Parser/DefaultParserTests.cs @@ -46,6 +46,7 @@ public class DefaultParserTests [InlineData("/manga/Btooom!/Vol.1/Chapter 1/1.cbz", "Btooom!~1~1")] [InlineData("/manga/Btooom!/Vol.1 Chapter 2/1.cbz", "Btooom!~1~2")] [InlineData("/manga/Monster/Ch. 001-016 [MangaPlus] [Digital] [amit34521]/Monster Ch. 001 [MangaPlus] [Digital] [amit34521]/13.jpg", "Monster~0~1")] + [InlineData("/manga/Hajime no Ippo/Artbook/Hajime no Ippo - Artbook.cbz", "Hajime no Ippo~0~0")] public void ParseFromFallbackFolders_ShouldParseSeriesVolumeAndChapter(string inputFile, string expectedParseInfo) { const string rootDirectory = "/manga/"; @@ -80,6 +81,7 @@ public class DefaultParserTests [Theory] [InlineData("/manga/Btooom!/Specials/Art Book.cbz", "Btooom!")] + [InlineData("/manga/Hajime no Ippo/Artbook/Hajime no Ippo - Artbook.cbz", "Hajime no Ippo")] public void ParseFromFallbackFolders_ShouldUseExistingSeriesName_NewScanLoop(string inputFile, string expectedParseInfo) { const string rootDirectory = "/manga/"; diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index 89b1112f5..20c1a27ae 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -195,6 +195,7 @@ public class MangaParserTests [InlineData("Манга Глава 2-2", "Манга")] [InlineData("Манга Том 1 3-4 Глава", "Манга")] [InlineData("Esquire 6권 2021년 10월호", "Esquire")] + [InlineData("Accel World: Vol 1", "Accel World")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename)); @@ -314,8 +315,8 @@ public class MangaParserTests [InlineData("Beastars SP01", false)] [InlineData("The League of Extraordinary Gentlemen", false)] [InlineData("The League of Extra-ordinary Gentlemen", false)] - [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown].epub", true)] - [InlineData("Dr. Ramune - Mysterious Disease Specialist v01 (2020) (Digital) (danke-Empire).cbz", false)] + [InlineData("Dr. Ramune - Mysterious Disease Specialist v01 (2020) (Digital) (danke-Empire)", false)] + [InlineData("Hajime no Ippo - Artbook", false)] public void IsMangaSpecialTest(string input, bool expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.IsMangaSpecial(input)); diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index f399cb790..b59ee097e 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -281,6 +281,17 @@ public class ArchiveServiceTests Assert.Equal("BTOOOM! - Duplicate", comicInfo.Series); } + [Fact] + public void ShouldHaveComicInfo_OutsideRoot_SharpCompress() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); + var archive = Path.Join(testDirectory, "ComicInfo_outside_root_SharpCompress.cb7"); + + var comicInfo = _archiveService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal("Fire Punch", comicInfo.Series); + } + #endregion #region CanParseComicInfo diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index 38a5da896..4665ab691 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -54,4 +54,28 @@ public class BookServiceTests Assert.Equal("Roger Starbuck,Junya Inoue", comicInfo.Writer); } + [Fact] + public void ShouldParseAsVolumeGroup_WithoutSeriesIndex() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var archive = Path.Join(testDirectory, "TitleWithVolume_NoSeriesOrSeriesIndex.epub"); + + var comicInfo = _bookService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal("1", comicInfo.Volume); + Assert.Equal("Accel World", comicInfo.Series); + } + + [Fact] + public void ShouldParseAsVolumeGroup_WithSeriesIndex() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var archive = Path.Join(testDirectory, "TitleWithVolume.epub"); + + var comicInfo = _bookService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal("1.0", comicInfo.Volume); + Assert.Equal("Accel World", comicInfo.Series); + } + } diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index 254d851fa..134dc2361 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -593,6 +593,23 @@ public class DirectoryServiceTests || outputFiles.Contains(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath("C:/manga/output/file (3).zip"))); } + [Fact] + public void CopyFilesToDirectory_ShouldRenameFilesToPassedNames() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile(MockUnixSupport.Path($"{testDirectory}file.zip"), new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/", new [] {"01"}); + var outputFiles = ds.GetFiles("/manga/output/").Select(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath).ToList(); + Assert.Single(outputFiles); + // For some reason, this has C:/ on directory even though everything is emulated (System.IO.Abstractions issue, not changing) + // https://github.com/TestableIO/System.IO.Abstractions/issues/831 + Assert.True(outputFiles.Contains(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath("/manga/output/01.zip")) + || outputFiles.Contains(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath("C:/manga/output/01.zip"))); + } + #endregion #region ListDirectory diff --git a/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_outside_root_SharpCompress.cb7 b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_outside_root_SharpCompress.cb7 new file mode 100644 index 000000000..14944cbfe Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_outside_root_SharpCompress.cb7 differ diff --git a/API.Tests/Services/Test Data/BookService/TitleWithVolume.epub b/API.Tests/Services/Test Data/BookService/TitleWithVolume.epub new file mode 100644 index 000000000..2d8c25b26 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/TitleWithVolume.epub differ diff --git a/API.Tests/Services/Test Data/BookService/TitleWithVolume_NoSeriesOrSeriesIndex.epub b/API.Tests/Services/Test Data/BookService/TitleWithVolume_NoSeriesOrSeriesIndex.epub new file mode 100644 index 000000000..56dc6f5a8 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/TitleWithVolume_NoSeriesOrSeriesIndex.epub differ diff --git a/API/API.csproj b/API/API.csproj index 4504e7804..ba8759d03 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -54,6 +54,7 @@ + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index b0d6c43ba..09e211d51 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -186,7 +186,7 @@ public class AccountController : BaseApiController .Include(u => u.UserPreferences) .SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpper()); - if (user == null) return Unauthorized("Invalid username"); + if (user == null) return Unauthorized("Your credentials are not correct"); var result = await _signInManager .CheckPasswordSignInAsync(user, loginDto.Password, true); @@ -198,7 +198,7 @@ public class AccountController : BaseApiController if (!result.Succeeded) { - return Unauthorized(result.IsNotAllowed ? "You must confirm your email first" : "Your credentials are not correct."); + return Unauthorized(result.IsNotAllowed ? "You must confirm your email first" : "Your credentials are not correct"); } // Update LastActive on account @@ -632,6 +632,11 @@ public class AccountController : BaseApiController return BadRequest("There was an error setting up your account. Please check the logs"); } + /// + /// Last step in authentication flow, confirms the email token for email + /// + /// + /// [AllowAnonymous] [HttpPost("confirm-email")] public async Task> ConfirmEmail(ConfirmEmailDto dto) @@ -640,7 +645,8 @@ public class AccountController : BaseApiController if (user == null) { - return BadRequest("The email does not match the registered email"); + _logger.LogInformation("confirm-email failed from invalid registered email: {Email}", dto.Email); + return BadRequest("Invalid email confirmation"); } // Validate Password and Username @@ -654,7 +660,11 @@ public class AccountController : BaseApiController } - if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); + if (!await ConfirmEmailToken(dto.Token, user)) + { + _logger.LogInformation("confirm-email failed from invalid token: {Token}", dto.Token); + return BadRequest("Invalid email confirmation"); + } user.UserName = dto.Username; user.ConfirmationToken = null; @@ -694,11 +704,15 @@ public class AccountController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByConfirmationToken(dto.Token); if (user == null) { - return BadRequest("Invalid Email Token"); + _logger.LogInformation("confirm-email failed from invalid registered email: {Email}", dto.Email); + return BadRequest("Invalid email confirmation"); } - if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); - + if (!await ConfirmEmailToken(dto.Token, user)) + { + _logger.LogInformation("confirm-email failed from invalid token: {Token}", dto.Token); + return BadRequest("Invalid email confirmation"); + } _logger.LogInformation("User is updating email from {OldEmail} to {NewEmail}", user.Email, dto.Email); var result = await _userManager.SetEmailAsync(user, dto.Email); @@ -728,12 +742,16 @@ public class AccountController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); if (user == null) { - return BadRequest("Invalid Details"); + return BadRequest("Invalid credentials"); } var result = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", dto.Token); - if (!result) return BadRequest("Unable to reset password, your email token is not correct."); + if (!result) + { + _logger.LogInformation("Unable to reset password, your email token is not correct: {@Dto}", dto); + return BadRequest("Invalid credentials"); + } var errors = await _accountService.ChangeUserPassword(user, dto.Password); return errors.Any() ? BadRequest(errors) : Ok("Password updated"); @@ -801,9 +819,13 @@ public class AccountController : BaseApiController public async Task> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto) { var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); - if (user == null) return BadRequest("This email is not on system"); + if (user == null) return BadRequest("Invalid credentials"); - if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); + if (!await ConfirmEmailToken(dto.Token, user)) + { + _logger.LogInformation("confirm-migration-email email token is invalid"); + return BadRequest("Invalid credentials"); + } await _unitOfWork.CommitAsync(); @@ -865,6 +887,10 @@ public class AccountController : BaseApiController [HttpPost("migrate-email")] public async Task> MigrateEmail(MigrateUserEmailDto dto) { + // If there is an admin account already, return + var users = await _unitOfWork.UserRepository.GetAdminUsersAsync(); + if (users.Any()) return BadRequest("Admin already exists"); + // Check if there is an existing invite var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); if (emailValidationErrors.Any()) diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index db5db71bb..73631e67c 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -83,8 +83,9 @@ public class ReaderController : BaseApiController } /// - /// Returns an image for a given chapter. Side effect: This will cache the chapter images for reading. + /// Returns an image for a given chapter. Will perform bounding checks /// + /// This will cache the chapter images for reading /// /// /// @@ -99,6 +100,7 @@ public class ReaderController : BaseApiController try { + // TODO: This code is very generic and repeated, see if we can refactor into a common method var path = _cacheService.GetCachedPagePath(chapter, page); if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}. Try refreshing to allow re-cache."); var format = Path.GetExtension(path).Replace(".", ""); @@ -128,7 +130,6 @@ public class ReaderController : BaseApiController if (page < 0) page = 0; var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); - // NOTE: I'm not sure why I need this flow here var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId); if (page > totalPages) { @@ -139,7 +140,7 @@ public class ReaderController : BaseApiController { var path = _cacheService.GetCachedBookmarkPagePath(seriesId, page); if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}"); - var format = Path.GetExtension(path).Replace(".", ""); + var format = Path.GetExtension(path).Replace(".", string.Empty); return PhysicalFile(path, "image/" + format, Path.GetFileName(path)); } diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 1428e81f9..b6ee2724d 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Comparators; diff --git a/API/DTOs/ReadingLists/UpdateReadingListDto.cs b/API/DTOs/ReadingLists/UpdateReadingListDto.cs index b61ab2a72..6be7b8f69 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListDto.cs @@ -1,10 +1,14 @@ -namespace API.DTOs.ReadingLists; +using System; +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.ReadingLists; public class UpdateReadingListDto { + [Required] public int ReadingListId { get; set; } - public string Title { get; set; } - public string Summary { get; set; } + public string Title { get; set; } = string.Empty; + public string Summary { get; set; } = string.Empty; public bool Promoted { get; set; } public bool CoverImageLocked { get; set; } } diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index c7115081b..904cc64b1 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -305,7 +305,7 @@ public class UserRepository : IUserRepository { return await _context.AppUserBookmark .Where(x => x.AppUserId == userId && x.SeriesId == seriesId) - .OrderBy(x => x.Page) + .OrderBy(x => x.Created) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -315,7 +315,7 @@ public class UserRepository : IUserRepository { return await _context.AppUserBookmark .Where(x => x.AppUserId == userId && x.VolumeId == volumeId) - .OrderBy(x => x.Page) + .OrderBy(x => x.Created) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -325,7 +325,7 @@ public class UserRepository : IUserRepository { return await _context.AppUserBookmark .Where(x => x.AppUserId == userId && x.ChapterId == chapterId) - .OrderBy(x => x.Page) + .OrderBy(x => x.Created) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -341,25 +341,27 @@ public class UserRepository : IUserRepository { var query = _context.AppUserBookmark .Where(x => x.AppUserId == userId) - .OrderBy(x => x.Page) + .OrderBy(x => x.Created) .AsNoTracking(); - if (!string.IsNullOrEmpty(filter.SeriesNameQuery)) - { - var seriesNameQueryNormalized = Services.Tasks.Scanner.Parser.Parser.Normalize(filter.SeriesNameQuery); - var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id, (bookmark, series) => new - { - bookmark, - series - }) - .Where(o => EF.Functions.Like(o.series.Name, $"%{filter.SeriesNameQuery}%") - || EF.Functions.Like(o.series.OriginalName, $"%{filter.SeriesNameQuery}%") - || EF.Functions.Like(o.series.LocalizedName, $"%{filter.SeriesNameQuery}%") - || EF.Functions.Like(o.series.NormalizedName, $"%{seriesNameQueryNormalized}%") - ); + if (string.IsNullOrEmpty(filter.SeriesNameQuery)) + return await query + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); - query = filterSeriesQuery.Select(o => o.bookmark); - } + var seriesNameQueryNormalized = Services.Tasks.Scanner.Parser.Parser.Normalize(filter.SeriesNameQuery); + var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id, (bookmark, series) => new + { + bookmark, + series + }) + .Where(o => EF.Functions.Like(o.series.Name, $"%{filter.SeriesNameQuery}%") + || EF.Functions.Like(o.series.OriginalName, $"%{filter.SeriesNameQuery}%") + || EF.Functions.Like(o.series.LocalizedName, $"%{filter.SeriesNameQuery}%") + || EF.Functions.Like(o.series.NormalizedName, $"%{seriesNameQueryNormalized}%") + ); + + query = filterSeriesQuery.Select(o => o.bookmark); return await query diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 15e68abeb..61f3b086d 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -79,10 +79,7 @@ public static class Seed { new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory}, new() {Key = ServerSettingKey.TaskScan, Value = "daily"}, - new() - { - Key = ServerSettingKey.LoggingLevel, Value = "Information" - }, // Not used from DB, but DB is sync with appSettings.json + new() {Key = ServerSettingKey.LoggingLevel, Value = "Debug"}, new() {Key = ServerSettingKey.TaskBackup, Value = "daily"}, new() { diff --git a/API/Logging/LogLevelOptions.cs b/API/Logging/LogLevelOptions.cs index 34d7d353f..51ed86632 100644 --- a/API/Logging/LogLevelOptions.cs +++ b/API/Logging/LogLevelOptions.cs @@ -71,7 +71,7 @@ public static class LogLevelOptions AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Warning; break; case "Information": - LogLevelSwitch.MinimumLevel = LogEventLevel.Error; + LogLevelSwitch.MinimumLevel = LogEventLevel.Information; MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Error; MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error; AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index b370f178d..211d85df7 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -331,7 +331,7 @@ public class ArchiveService : IArchiveService private static bool IsComicInfoArchiveEntry(string fullName, string name) { return !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(fullName) - && name.Equals(ComicInfoFilename, StringComparison.OrdinalIgnoreCase) + && name.EndsWith(ComicInfoFilename, StringComparison.OrdinalIgnoreCase) && !name.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith); } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 728b6f8ff..8156a56ff 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -451,9 +451,22 @@ public class BookService : IBookService info.Series = metadataItem.Content; info.SeriesSort = metadataItem.Content; break; + case "calibre:series_index": + info.Volume = metadataItem.Content; + break; } } + var hasVolumeInSeries = !Tasks.Scanner.Parser.Parser.ParseVolume(info.Title) + .Equals(Tasks.Scanner.Parser.Parser.DefaultVolume); + + if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) + { + // This is likely a light novel for which we can set series from parsed title + info.Series = Tasks.Scanner.Parser.Parser.ParseSeries(info.Title); + info.Volume = Tasks.Scanner.Parser.Parser.ParseVolume(info.Title); + } + return info; } catch (Exception ex) diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index a150bde22..7cf00bf57 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -101,7 +102,7 @@ public class CacheService : ICacheService var extractPath = GetCachePath(chapterId); if (_directoryService.Exists(extractPath)) return chapter; - var files = chapter.Files.ToList(); + var files = chapter?.Files.ToList(); ExtractChapterFiles(extractPath, files); return chapter; @@ -223,6 +224,8 @@ public class CacheService : ICacheService return string.Empty; } + if (page > files.Length) page = files.Length; + // Since array is 0 based, we need to keep that in account (only affects last image) return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page); } @@ -234,8 +237,8 @@ public class CacheService : ICacheService var bookmarkDtos = await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(userId, seriesId); var files = (await _bookmarkService.GetBookmarkFilesById(bookmarkDtos.Select(b => b.Id))).ToList(); - _directoryService.CopyFilesToDirectory(files, destDirectory); - _directoryService.Flatten(destDirectory); + _directoryService.CopyFilesToDirectory(files, destDirectory, + Enumerable.Range(1, files.Count).Select(i => i + string.Empty).ToList()); return files.Count; } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index dbf7214cb..25119dbe0 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -37,6 +37,7 @@ public interface IDirectoryService IEnumerable ListDirectory(string rootPath); Task ReadFileAsync(string path); bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = ""); + bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, IList newFilenames); bool Exists(string directory); void CopyFileToDirectory(string fullFilePath, string targetDirectory); int TraverseTreeParallelForEach(string root, Action action, string searchPattern, ILogger logger); @@ -424,6 +425,46 @@ public class DirectoryService : IDirectoryService return true; } + /// + /// Copies files to a destination directory. If the destination directory doesn't exist, this will create it. + /// + /// If a file already exists in dest, this will rename as (2). It does not support multiple iterations of this. Overwriting is not supported. + /// + /// + /// A list that matches one to one with filePaths. Each filepath will be renamed to newFilenames + /// + public bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, IList newFilenames) + { + ExistOrCreate(directoryPath); + string currentFile = null; + var index = 0; + try + { + foreach (var file in filePaths) + { + currentFile = file; + + if (!FileSystem.File.Exists(file)) + { + _logger.LogError("Unable to copy {File} to {DirectoryPath} as it doesn't exist", file, directoryPath); + continue; + } + var fileInfo = FileSystem.FileInfo.FromFileName(file); + var targetFile = FileSystem.FileInfo.FromFileName(RenameFileForCopy(newFilenames[index] + fileInfo.Extension, directoryPath)); + + fileInfo.CopyTo(FileSystem.Path.Join(directoryPath, targetFile.Name)); + index++; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath); + return false; + } + + return true; + } + /// /// Generates the combined filepath given a prepend (optional), output directory path, and a full input file path. /// If the output file already exists, will append (1), (2), etc until it can be written out @@ -434,30 +475,32 @@ public class DirectoryService : IDirectoryService /// private string RenameFileForCopy(string fileToCopy, string directoryPath, string prepend = "") { - var fileInfo = FileSystem.FileInfo.FromFileName(fileToCopy); - var filename = prepend + fileInfo.Name; - - var targetFile = FileSystem.FileInfo.FromFileName(FileSystem.Path.Join(directoryPath, filename)); - if (!targetFile.Exists) + while (true) { - return targetFile.FullName; - } + var fileInfo = FileSystem.FileInfo.FromFileName(fileToCopy); + var filename = prepend + fileInfo.Name; - var noExtension = FileSystem.Path.GetFileNameWithoutExtension(fileInfo.Name); - if (FileCopyAppend.IsMatch(noExtension)) - { - var match = FileCopyAppend.Match(noExtension).Value; - var matchNumber = match.Replace("(", string.Empty).Replace(")", string.Empty); - noExtension = noExtension.Replace(match, $"({int.Parse(matchNumber) + 1})"); - } - else - { - noExtension += " (1)"; - } + var targetFile = FileSystem.FileInfo.FromFileName(FileSystem.Path.Join(directoryPath, filename)); + if (!targetFile.Exists) + { + return targetFile.FullName; + } - var newFilename = prepend + noExtension + - FileSystem.Path.GetExtension(fileInfo.Name); - return RenameFileForCopy(FileSystem.Path.Join(directoryPath, newFilename), directoryPath, prepend); + var noExtension = FileSystem.Path.GetFileNameWithoutExtension(fileInfo.Name); + if (FileCopyAppend.IsMatch(noExtension)) + { + var match = FileCopyAppend.Match(noExtension).Value; + var matchNumber = match.Replace("(", string.Empty).Replace(")", string.Empty); + noExtension = noExtension.Replace(match, $"({int.Parse(matchNumber) + 1})"); + } + else + { + noExtension += " (1)"; + } + + var newFilename = prepend + noExtension + FileSystem.Path.GetExtension(fileInfo.Name); + fileToCopy = FileSystem.Path.Join(directoryPath, newFilename); + } } /// diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 3f2122a08..551d1b668 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -72,8 +72,23 @@ public class ReadingItemService : IReadingItemService // This catches when original library type is Manga/Comic and when parsing with non if (Tasks.Scanner.Parser.Parser.IsEpub(path) && Tasks.Scanner.Parser.Parser.ParseVolume(info.Series) != Tasks.Scanner.Parser.Parser.DefaultVolume) // Shouldn't this be info.Volume != DefaultVolume? { - var info2 = _defaultParser.Parse(path, rootPath, LibraryType.Book); - info.Merge(info2); + var hasVolumeInTitle = !Tasks.Scanner.Parser.Parser.ParseVolume(info.Title) + .Equals(Tasks.Scanner.Parser.Parser.DefaultVolume); + var hasVolumeInSeries = !Tasks.Scanner.Parser.Parser.ParseVolume(info.Series) + .Equals(Tasks.Scanner.Parser.Parser.DefaultVolume); + + if (string.IsNullOrEmpty(info.ComicInfo?.Volume) && hasVolumeInTitle && (hasVolumeInSeries || string.IsNullOrEmpty(info.Series))) + { + // This is likely a light novel for which we can set series from parsed title + info.Series = Tasks.Scanner.Parser.Parser.ParseSeries(info.Title); + info.Volumes = Tasks.Scanner.Parser.Parser.ParseVolume(info.Title); + } + else + { + var info2 = _defaultParser.Parse(path, rootPath, LibraryType.Book); + info.Merge(info2); + } + } info.ComicInfo = GetComicInfo(path); diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index fea30b7fe..fee51f562 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -192,6 +192,7 @@ public class LibraryWatcher : ILibraryWatcher /// This is public only because Hangfire will invoke it. Do not call external to this class. /// File or folder that changed /// If the change is on a directory and not a file + [DisableConcurrentExecution(60)] // ReSharper disable once MemberCanBePrivate.Global public async Task ProcessChange(string filePath, bool isDirectoryChange = false) { diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index dbd23d970..1a23af727 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -38,7 +38,10 @@ public class SeriesModified public IEnumerable LibraryRoots { get; set; } } - +/// +/// Responsible for taking parsed info from ReadingItemService and DirectoryService and combining them to emit DB work +/// on a series by series. +/// public class ParseScannedFiles { private readonly ILogger _logger; diff --git a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs index 4bab428a3..072b1e44e 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -34,6 +34,8 @@ public class DefaultParser : IDefaultParser public ParserInfo Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga) { var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); + // TODO: Potential Bug: This will return null, but on Image libraries, if all images, we would want to include this. + if (Parser.IsCoverImage(_directoryService.FileSystem.Path.GetFileName(filePath))) return null; ParserInfo ret; if (Parser.IsEpub(filePath)) @@ -62,7 +64,6 @@ public class DefaultParser : IDefaultParser }; } - if (Parser.IsCoverImage(_directoryService.FileSystem.Path.GetFileName(filePath))) return null; if (Parser.IsImage(filePath)) { diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 8a7e16933..13cce0feb 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -200,11 +200,11 @@ public static class Parser MatchOptions, RegexTimeout), // [dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz new Regex( - @"(?.*) (\b|_|-)(vol)\.?(\s|-|_)?\d+", + @"(?.+?):? (\b|_|-)(vol)\.?(\s|-|_)?\d+", MatchOptions, RegexTimeout), // [xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans] new Regex( - @"(?.*) (\b|_|-)(vol)(ume)", + @"(?.+?):? (\b|_|-)(vol)(ume)", MatchOptions, RegexTimeout), //Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans] @@ -596,7 +596,7 @@ public static class Parser private static readonly Regex ComicSpecialRegex = new Regex( // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - $@"\b(?:{CommonSpecial}|\d.+?\WAnnual|Annual\W\d.+?|Book \d.+?|Compendium \d.+?|Omnibus \d.+?|FCBD \d.+?|Absolute \d.+?|Preview \d.+?|Hors[ -]S[ée]rie|TPB|HS|THS)\b", + $@"\b(?:{CommonSpecial}|\d.+?(\W|-|^)Annual|Annual(\W|-|$)|Book \d.+?|Compendium(\W|-|$|\s.+?)|Omnibus(\W|-|$|\s.+?)|FCBD \d.+?|Absolute(\W|-|$|\s.+?)|Preview(\W|-|$|\s.+?)|Hors[ -]S[ée]rie|TPB|HS|THS)\b", MatchOptions, RegexTimeout ); diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 18cb219e0..f934e6ba6 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -160,6 +160,7 @@ public class ScannerService : IScannerService var sw = Stopwatch.StartNew(); var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); + if (series == null) return; // This can occur when UI deletes a series but doesn't update and user re-requests update var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId, LibraryIncludes.Folders); var libraryPaths = library.Folders.Select(f => f.Path).ToList(); diff --git a/API/Startup.cs b/API/Startup.cs index 00351a3fa..a9fef97b8 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -177,7 +177,8 @@ public class Startup services.AddHangfire(configuration => configuration .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() - .UseSQLiteStorage("config/Hangfire.db")); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted + .UseInMemoryStorage()); + //.UseSQLiteStorage("config/Hangfire.db")); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted (and locking can cause high utilization) // Add the processing server as IHostedService services.AddHangfireServer(options => diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index a46dfdf4b..657da79cb 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -4,7 +4,7 @@ net6.0 kavitareader.com Kavita - 0.6.0.0 + 0.6.1.0 en true diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 1e6b9b82b..aba7c9f7b 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -12469,9 +12469,9 @@ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "requires": { "brace-expansion": "^1.1.7" } @@ -15417,9 +15417,9 @@ "dev": true }, "swiper": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/swiper/-/swiper-8.0.6.tgz", - "integrity": "sha512-Ssyu1+FeNATF/G8e84QG+ZUNtUOAZ5vngdgxzczh0oWZPhGUVgkdv+BoePUuaCXLAFXnwVpNjgLIcGnxMdmWPA==", + "version": "8.4.4", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-8.4.4.tgz", + "integrity": "sha512-jA/8BfOZwT8PqPSnMX0TENZYitXEhNa7ZSNj1Diqh5LZyUJoBQaZcqAiPQ/PIg1+IPaRn/V8ZYVb0nxHMh51yw==", "requires": { "dom7": "^4.0.4", "ssr-window": "^4.0.2" diff --git a/UI/Web/package.json b/UI/Web/package.json index c78cdc29e..7df5cdf86 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -44,7 +44,7 @@ "ngx-toastr": "^14.2.1", "requires": "^1.0.2", "rxjs": "~7.5.4", - "swiper": "^8.0.6", + "swiper": "^8.4.4", "tslib": "^2.3.1", "webpack-bundle-analyzer": "^4.5.0", "zone.js": "~0.11.4" diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 489c5c30d..0fd52cd60 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -264,7 +264,7 @@ export class ActionService implements OnDestroy { markChapterAsUnread(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) { this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, 0).pipe(take(1)).subscribe(results => { chapter.pagesRead = 0; - this.toastr.success('Marked as unread'); + this.toastr.success('Marked as Unread'); if (callback) { callback(chapter); } @@ -306,7 +306,7 @@ export class ActionService implements OnDestroy { volume.chapters?.forEach(c => c.pagesRead = 0); }); chapters?.forEach(c => c.pagesRead = 0); - this.toastr.success('Marked as Read'); + this.toastr.success('Marked as Unread'); if (callback) { callback(); diff --git a/UI/Web/src/app/_services/jumpbar.service.ts b/UI/Web/src/app/_services/jumpbar.service.ts index 43dc5ed3d..7c9bf8478 100644 --- a/UI/Web/src/app/_services/jumpbar.service.ts +++ b/UI/Web/src/app/_services/jumpbar.service.ts @@ -36,15 +36,15 @@ export class JumpbarService { const removalTimes = Math.ceil(removeCount / 2); const midPoint = Math.floor(jumpBarKeys.length / 2); jumpBarKeysToRender.push(jumpBarKeys[0]); - this.removeFirstPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender); + this._removeFirstPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender); jumpBarKeysToRender.push(jumpBarKeys[midPoint]); - this.removeSecondPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender); + this._removeSecondPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender); jumpBarKeysToRender.push(jumpBarKeys[jumpBarKeys.length - 1]); return jumpBarKeysToRender; } - removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array, jumpBarKeysToRender: Array) { + _removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array, jumpBarKeysToRender: Array) { const removedIndexes: Array = []; for(let removal = 0; removal < numberOfRemovals; removal++) { let min = 100000000; @@ -62,7 +62,7 @@ export class JumpbarService { } } - removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array, jumpBarKeysToRender: Array) { + _removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array, jumpBarKeysToRender: Array) { const removedIndexes: Array = []; for(let removal = 0; removal < numberOfRemovals; removal++) { let min = 100000000; @@ -80,4 +80,35 @@ export class JumpbarService { if (!removedIndexes.includes(i)) jumpBarKeysToRender.push(jumpBarKeys[i]); } } + + /** + * + * @param data An array of objects + * @param keySelector A method to fetch a string from the object, which is used to classify the JumpKey + * @returns + */ + getJumpKeys(data :Array, keySelector: (data: any) => string) { + const keys: {[key: string]: number} = {}; + data.forEach(obj => { + let ch = keySelector(obj).charAt(0); + if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) { + ch = '#'; + } + if (!keys.hasOwnProperty(ch)) { + keys[ch] = 0; + } + keys[ch] += 1; + }); + return Object.keys(keys).map(k => { + return { + key: k, + size: keys[k], + title: k.toUpperCase() + } + }).sort((a, b) => { + if (a.key < b.key) return -1; + if (a.key > b.key) return 1; + return 0; + }); + } } diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.html b/UI/Web/src/app/admin/role-selector/role-selector.component.html index 1eb806aab..5d97cdd0f 100644 --- a/UI/Web/src/app/admin/role-selector/role-selector.component.html +++ b/UI/Web/src/app/admin/role-selector/role-selector.component.html @@ -3,7 +3,7 @@
  • + [(ngModel)]="role.selected" [disabled]="role.disabled" name="role" (ngModelChange)="handleModelUpdate()">
  • diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.ts b/UI/Web/src/app/admin/role-selector/role-selector.component.ts index e21c2d67c..f748e3a70 100644 --- a/UI/Web/src/app/admin/role-selector/role-selector.component.ts +++ b/UI/Web/src/app/admin/role-selector/role-selector.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { Member } from 'src/app/_models/member'; import { User } from 'src/app/_models/user'; @@ -8,7 +8,8 @@ import { MemberService } from 'src/app/_services/member.service'; @Component({ selector: 'app-role-selector', templateUrl: './role-selector.component.html', - styleUrls: ['./role-selector.component.scss'] + styleUrls: ['./role-selector.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class RoleSelectorComponent implements OnInit { @@ -23,9 +24,9 @@ export class RoleSelectorComponent implements OnInit { @Output() selected: EventEmitter = new EventEmitter(); allRoles: string[] = []; - selectedRoles: Array<{selected: boolean, data: string}> = []; + selectedRoles: Array<{selected: boolean, disabled: boolean, data: string}> = []; - constructor(public modal: NgbActiveModal, private accountService: AccountService, private memberService: MemberService) { } + constructor(public modal: NgbActiveModal, private accountService: AccountService, private readonly cdRef: ChangeDetectorRef) { } ngOnInit(): void { this.accountService.getRoles().subscribe(roles => { @@ -36,8 +37,9 @@ export class RoleSelectorComponent implements OnInit { roles = roles.filter(item => !bannedRoles.includes(item)); this.allRoles = roles; this.selectedRoles = roles.map(item => { - return {selected: false, data: item}; + return {selected: false, disabled: false, data: item}; }); + this.cdRef.markForCheck(); this.preselect(); this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data)); }); @@ -51,11 +53,25 @@ export class RoleSelectorComponent implements OnInit { foundRole[0].selected = true; } }); + this.cdRef.markForCheck(); } } handleModelUpdate() { - this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data)); + const roles = this.selectedRoles.filter(item => item.selected).map(item => item.data); + if (roles.filter(r => r === 'Admin').length > 0) { + // Disable all other items as Admin is selected + this.selectedRoles.filter(item => item.data !== 'Admin').forEach(e => { + e.disabled = true; + }); + } else { + // Re-enable everything + this.selectedRoles.forEach(e => { + e.disabled = false; + }); + } + this.cdRef.markForCheck(); + this.selected.emit(roles); } } diff --git a/UI/Web/src/app/book-reader/_models/book-white-theme.ts b/UI/Web/src/app/book-reader/_models/book-white-theme.ts index 27405b577..f25ab96a1 100644 --- a/UI/Web/src/app/book-reader/_models/book-white-theme.ts +++ b/UI/Web/src/app/book-reader/_models/book-white-theme.ts @@ -20,7 +20,7 @@ export const BookWhiteTheme = ` --drawer-text-color: black; --drawer-pagination-horizontal-rule: inset 0 -1px 0 rgb(255 255 255 / 20%); --drawer-pagination-border: 1px solid rgb(0 0 0 / 13%); - + /* Accordion */ --accordion-header-text-color: rgba(74, 198, 148, 0.9); @@ -103,7 +103,7 @@ export const BookWhiteTheme = ` .book-content *:not(input), .book-content *:not(select), .book-content *:not(code), .book-content *:not(:link), .book-content *:not(.ngx-toastr) { - color: #dcdcdc !important; + color: black !important; } .book-content code { @@ -122,19 +122,19 @@ background-color: initial !important; .book-content *:not(code), .book-content *:not(a) { - background-color: black; + background-color: white; box-shadow: none; text-shadow: none; border-radius: unset; color: #dcdcdc !important; } - -.book-content :visited, .book-content :visited *, .book-content :visited *[class] {color: rgb(211, 138, 138) !important} -.book-content :link:not(cite), :link .book-content *:not(cite) {color: #8db2e5 !important} + +.book-content :visited, .book-content :visited *, .book-content :visited *[class] {color: rgb(240, 50, 50) !important} +.book-content :link:not(cite), :link .book-content *:not(cite) {color: #00f !important} .btn-check:checked + .btn { color: white; background-color: var(--primary-color); } -`; \ No newline at end of file +`; diff --git a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.html b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.html index a43e90936..78dbf6dc2 100644 --- a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.html +++ b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.html @@ -11,6 +11,7 @@ [filterSettings]="filterSettings" [trackByIdentity]="trackByIdentity" [refresh]="refresh" + [jumpBarKeys]="jumpbarKeys" (applyFilter)="updateFilter($event)" > diff --git a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts index 42c4cfaee..e37c96aea 100644 --- a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts +++ b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts @@ -8,12 +8,14 @@ import { ConfirmService } from 'src/app/shared/confirm.service'; import { DownloadService } from 'src/app/shared/_services/download.service'; import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service'; import { KEY_CODES } from 'src/app/shared/_services/utility.service'; +import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; import { PageBookmark } from 'src/app/_models/page-bookmark'; import { Pagination } from 'src/app/_models/pagination'; import { Series } from 'src/app/_models/series'; import { FilterEvent, SeriesFilter } from 'src/app/_models/series-filter'; import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; import { ImageService } from 'src/app/_services/image.service'; +import { JumpbarService } from 'src/app/_services/jumpbar.service'; import { ReaderService } from 'src/app/_services/reader.service'; import { SeriesService } from 'src/app/_services/series.service'; @@ -32,6 +34,7 @@ export class BookmarksComponent implements OnInit, OnDestroy { downloadingSeries: {[id: number]: boolean} = {}; clearingSeries: {[id: number]: boolean} = {}; actions: ActionItem[] = []; + jumpbarKeys: Array = []; pagination!: Pagination; filter: SeriesFilter | undefined = undefined; @@ -50,7 +53,8 @@ export class BookmarksComponent implements OnInit, OnDestroy { private confirmService: ConfirmService, public bulkSelectionService: BulkSelectionService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, private router: Router, private readonly cdRef: ChangeDetectorRef, - private filterUtilityService: FilterUtilitiesService, private route: ActivatedRoute) { + private filterUtilityService: FilterUtilitiesService, private route: ActivatedRoute, + private jumpbarService: JumpbarService) { this.filterSettings.ageRatingDisabled = true; this.filterSettings.collectionDisabled = true; this.filterSettings.formatDisabled = true; @@ -158,6 +162,7 @@ export class BookmarksComponent implements OnInit, OnDestroy { const ids = Object.keys(this.seriesIds).map(k => parseInt(k, 10)); this.seriesService.getAllSeriesByIds(ids).subscribe(series => { + this.jumpbarKeys = this.jumpbarService.getJumpKeys(series, (t: Series) => t.name); this.series = series; this.loadingBookmarks = false; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/collections/all-collections/all-collections.component.html b/UI/Web/src/app/collections/all-collections/all-collections.component.html index b64402ea3..85937d6c8 100644 --- a/UI/Web/src/app/collections/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/all-collections/all-collections.component.html @@ -16,6 +16,7 @@ - There are no collections. Try creating one . + There are no collections. + Try creating one  \ No newline at end of file diff --git a/UI/Web/src/app/collections/all-collections/all-collections.component.ts b/UI/Web/src/app/collections/all-collections/all-collections.component.ts index 02588d01a..0b61669af 100644 --- a/UI/Web/src/app/collections/all-collections/all-collections.component.ts +++ b/UI/Web/src/app/collections/all-collections/all-collections.component.ts @@ -1,15 +1,18 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { map, of, Subject, takeUntil } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component'; -import { UtilityService } from 'src/app/shared/_services/utility.service'; import { CollectionTag } from 'src/app/_models/collection-tag'; import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; import { Tag } from 'src/app/_models/tag'; +import { AccountService } from 'src/app/_services/account.service'; import { ActionItem, ActionFactoryService, Action } from 'src/app/_services/action-factory.service'; import { CollectionTagService } from 'src/app/_services/collection-tag.service'; import { ImageService } from 'src/app/_services/image.service'; +import { JumpbarService } from 'src/app/_services/jumpbar.service'; @Component({ @@ -18,20 +21,23 @@ import { ImageService } from 'src/app/_services/image.service'; styleUrls: ['./all-collections.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class AllCollectionsComponent implements OnInit { +export class AllCollectionsComponent implements OnInit, OnDestroy { isLoading: boolean = true; collections: CollectionTag[] = []; collectionTagActions: ActionItem[] = []; jumpbarKeys: Array = []; trackByIdentity = (index: number, item: CollectionTag) => `${item.id}_${item.title}`; + isAdmin$: Observable = of(false); + private readonly onDestroy = new Subject(); filterOpen: EventEmitter = new EventEmitter(); constructor(private collectionService: CollectionTagService, private router: Router, private actionFactoryService: ActionFactoryService, private modalService: NgbModal, - private titleService: Title, private utilityService: UtilityService, - private readonly cdRef: ChangeDetectorRef, public imageSerivce: ImageService) { + private titleService: Title, private jumpbarService: JumpbarService, + private readonly cdRef: ChangeDetectorRef, public imageSerivce: ImageService, + public accountService: AccountService) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.titleService.setTitle('Kavita - Collections'); } @@ -40,6 +46,15 @@ export class AllCollectionsComponent implements OnInit { this.loadPage(); this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)); this.cdRef.markForCheck(); + this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), map(user => { + if (!user) return false; + return this.accountService.hasAdminRole(user); + })); + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); } @@ -54,7 +69,7 @@ export class AllCollectionsComponent implements OnInit { this.collectionService.allTags().subscribe(tags => { this.collections = tags; this.isLoading = false; - this.jumpbarKeys = this.utilityService.getJumpKeys(tags, (t: Tag) => t.title); + this.jumpbarKeys = this.jumpbarService.getJumpKeys(tags, (t: Tag) => t.title); this.cdRef.markForCheck(); }); } diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts index b105f7007..6627c6d50 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts @@ -21,6 +21,7 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti import { ActionService } from 'src/app/_services/action.service'; import { CollectionTagService } from 'src/app/_services/collection-tag.service'; import { ImageService } from 'src/app/_services/image.service'; +import { JumpbarService } from 'src/app/_services/jumpbar.service'; import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; import { ScrollService } from 'src/app/_services/scroll.service'; import { SeriesService } from 'src/app/_services/series.service'; @@ -124,7 +125,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService, - private modalService: NgbModal, private titleService: Title, + private modalService: NgbModal, private titleService: Title, private jumpbarService: JumpbarService, public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService, private filterUtilityService: FilterUtilitiesService, private utilityService: UtilityService, @Inject(DOCUMENT) private document: Document, private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService) { @@ -210,7 +211,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten this.seriesService.getAllSeries(undefined, undefined, this.filter).pipe(take(1)).subscribe(series => { this.series = series.result; this.seriesPagination = series.pagination; - this.jumpbarKeys = this.utilityService.getJumpKeys(this.series, (series: Series) => series.name); + this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (series: Series) => series.name); this.isLoading = false; window.scrollTo(0, 0); this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/manga-reader/fullscreen-icon.pipe.ts b/UI/Web/src/app/manga-reader/_pipes/fullscreen-icon.pipe.ts similarity index 100% rename from UI/Web/src/app/manga-reader/fullscreen-icon.pipe.ts rename to UI/Web/src/app/manga-reader/_pipes/fullscreen-icon.pipe.ts diff --git a/UI/Web/src/app/manga-reader/_pipes/layout-mode-icon.pipe.ts b/UI/Web/src/app/manga-reader/_pipes/layout-mode-icon.pipe.ts new file mode 100644 index 000000000..26f43dfee --- /dev/null +++ b/UI/Web/src/app/manga-reader/_pipes/layout-mode-icon.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { LayoutMode } from '../_models/layout-mode'; + +@Pipe({ + name: 'layoutModeIcon' +}) +export class LayoutModeIconPipe implements PipeTransform { + + transform(layoutMode: LayoutMode): string { + switch (layoutMode) { + case LayoutMode.Single: + return 'none'; + case LayoutMode.Double: + return 'double'; + case LayoutMode.DoubleReversed: + return 'double-reversed'; + } + } + +} diff --git a/UI/Web/src/app/manga-reader/_pipes/reader-mode-icon.pipe.ts b/UI/Web/src/app/manga-reader/_pipes/reader-mode-icon.pipe.ts new file mode 100644 index 000000000..5017ad755 --- /dev/null +++ b/UI/Web/src/app/manga-reader/_pipes/reader-mode-icon.pipe.ts @@ -0,0 +1,22 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { ReaderMode } from 'src/app/_models/preferences/reader-mode'; + +@Pipe({ + name: 'readerModeIcon' +}) +export class ReaderModeIconPipe implements PipeTransform { + + transform(readerMode: ReaderMode): string { + switch(readerMode) { + case ReaderMode.LeftRight: + return 'fa-exchange-alt'; + case ReaderMode.UpDown: + return 'fa-exchange-alt fa-rotate-90'; + case ReaderMode.Webtoon: + return 'fa-arrows-alt-v'; + default: + return ''; + } + } + +} diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/manga-reader.component.html index 21c28f7fd..aca92651b 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/manga-reader.component.html @@ -121,7 +121,7 @@
    @@ -197,7 +197,6 @@ -