From 4a93b5c715869412b0039af06337f536624ed040 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Mon, 21 Mar 2022 09:26:49 -0500 Subject: [PATCH] Feature/enhancements and more (#1166) * Moved libraryType into chapter info * Fixed a bug where you could not reset cover on a series * Patched in relevant changes from another polish branch * Refactored invite user setup to shift the checking for accessibility to the backend and always show the link. This will help with users who have some unique setups in docker. * Refactored invite user to always print the url to setup a new account. * Single page renderer uses canvasImage rather than re-requesting and relying on cache * Fixed a rendering issue where fit to split on single on a cover wouldn't force width scaling just for that image * Fixed a rendering bug with split image functionality * Added title to copy button * Fixed a bug in GetContinuePoint when a chapter is added to an already read volume and a new chapter is added loose leaf. The loose leaf would be prioritized over the volume chapter. Refactored 2 methods from controller into service and unit tested. * Fixed a bug on opening a volume in series detail that had a chapter added to it after the volume (0 chapter) was read would cause a loose leaf chapter to be opened. * Added mark as read/actionables on Files in volume detail modal. Fixed a bug where we were showing the wrong page count in a volume detail modal. * Removed OnDeck page and replaced it with a pre-filtered All-Series. Hooked up the ability to pass read state to the filter via query params. Fixed some spacing on filter post bootstrap update. * Fixed up some poor documentation on FilterDto. * Some string equals enhancements to reduce extra allocations * Fixed an issue when trying to download via a url, to remove query parameters to get the format * Made an optimization to Normalize method to reduce memory pressure by 100MB over the course of a scan (16k files) * Adjusted the styles on dashboard for first time setup and used a routerlink rather than href to avoid a fresh load. * Use framgment on router link * Hooked in the ability to search by release year (along with series optionally) and series will be returned back. * Fixed a bug in the filter format code where it was sending the wrong type * Only show clear all on typeahead when there are at least one selected item * Cleaned up the styles of the styles of the typeahead * Removed some dead code * Implemented the ability to filter against a series name. * Fixed filter top offset * Ensure that when we add or remove libraries, the side nav of users gets updated. * Tweaked the width on the mobile side nav * Close side nav on clicking overlay on mobile viewport * Don't show a pointer if the carousel section title is not actually selectable * Removed the User profile on the side nav so home is always first. Tweaked styles to match * Fixed up some poor documentation on FilterDto. * Fixed a bug where Latest read date wasn't being set due to an early short circuit. * When sending the chapter file, format the title of the FeedEntry more like Series Detail. * Removed dead code --- API.Benchmark/ParserBenchmarks.cs | 4 +- API.Tests/Parser/ParserTest.cs | 1 + API.Tests/Services/ReaderServiceTests.cs | 176 ++++++++++++++++++ API/Controllers/AccountController.cs | 16 +- API/Controllers/DownloadController.cs | 2 + API/Controllers/LibraryController.cs | 10 +- API/Controllers/OPDSController.cs | 13 +- API/Controllers/ReaderController.cs | 18 +- API/Controllers/UploadController.cs | 2 +- API/DTOs/Account/InviteUserDto.cs | 2 - API/DTOs/Account/InviteUserResponse.cs | 13 ++ API/DTOs/Filtering/FilterDto.cs | 30 +-- API/DTOs/Reader/ChapterInfoDto.cs | 1 + API/Data/Repositories/ChapterRepository.cs | 8 +- API/Data/Repositories/SeriesRepository.cs | 54 ++++-- API/Data/Repositories/UserRepository.cs | 3 +- API/Parser/Parser.cs | 12 +- API/Services/EmailService.cs | 8 +- API/Services/ReaderService.cs | 38 +++- API/Services/SeriesService.cs | 87 ++++----- API/SignalR/MessageFactory.cs | 39 ++-- .../_models/events/library-modified-event.ts | 4 + .../src/app/_models/invite-user-response.ts | 10 + UI/Web/src/app/_models/series-filter.ts | 1 + UI/Web/src/app/_services/account.service.ts | 5 +- .../src/app/_services/message-hub.service.ts | 12 ++ UI/Web/src/app/_services/series.service.ts | 1 + UI/Web/src/app/admin/admin.module.ts | 5 +- .../invite-user/invite-user.component.html | 24 ++- .../invite-user/invite-user.component.ts | 23 +-- .../app/all-series/all-series.component.html | 2 +- .../app/all-series/all-series.component.ts | 1 - .../announcements.component.html | 6 +- .../app/announcements/announcements.module.ts | 4 +- UI/Web/src/app/app-routing.module.ts | 2 - UI/Web/src/app/app.module.ts | 3 - .../card-details-modal.component.html | 14 +- .../edit-series-modal.component.ts | 22 +-- .../card-detail-layout.component.html | 6 +- .../card-detail-layout.component.ts | 4 +- .../carousel-reel.component.html | 2 +- .../carousel-reel.component.scss | 4 + .../carousel-reel/carousel-reel.component.ts | 1 + UI/Web/src/app/library/library.component.html | 20 +- UI/Web/src/app/library/library.component.ts | 5 +- .../app/manga-reader/_models/chapter-info.ts | 2 + .../manga-reader/manga-reader.component.html | 52 ++---- .../manga-reader/manga-reader.component.scss | 6 +- .../manga-reader/manga-reader.component.ts | 27 +-- .../metadata-filter.component.html | 22 ++- .../metadata-filter.component.ts | 29 ++- UI/Web/src/app/on-deck/on-deck.component.html | 19 -- UI/Web/src/app/on-deck/on-deck.component.scss | 0 UI/Web/src/app/on-deck/on-deck.component.ts | 133 ------------- .../reading-list-detail.component.html | 2 +- .../reading-lists.component.html | 2 +- .../series-detail/series-detail.component.ts | 9 +- .../series-metadata-detail.component.html | 2 - .../app/shared/_services/utility.service.ts | 12 ++ .../side-nav-companion-bar.component.ts | 5 - .../sidenav/side-nav/side-nav.component.html | 14 +- .../sidenav/side-nav/side-nav.component.scss | 14 +- .../sidenav/side-nav/side-nav.component.ts | 23 ++- .../app/typeahead/typeahead.component.html | 4 +- .../app/typeahead/typeahead.component.scss | 12 ++ .../src/app/typeahead/typeahead.component.ts | 2 - .../api-key/api-key.component.html | 2 +- .../app/user-settings/user-settings.module.ts | 3 +- 68 files changed, 663 insertions(+), 451 deletions(-) create mode 100644 API/DTOs/Account/InviteUserResponse.cs create mode 100644 UI/Web/src/app/_models/events/library-modified-event.ts create mode 100644 UI/Web/src/app/_models/invite-user-response.ts delete mode 100644 UI/Web/src/app/on-deck/on-deck.component.html delete mode 100644 UI/Web/src/app/on-deck/on-deck.component.scss delete mode 100644 UI/Web/src/app/on-deck/on-deck.component.ts diff --git a/API.Benchmark/ParserBenchmarks.cs b/API.Benchmark/ParserBenchmarks.cs index 98e83eb00..63adc6985 100644 --- a/API.Benchmark/ParserBenchmarks.cs +++ b/API.Benchmark/ParserBenchmarks.cs @@ -54,7 +54,7 @@ namespace API.Benchmark { foreach (var name in _names) { - if ((name + ".epub").ToLower() == ".epub") + if ((name).ToLower() == ".epub") { /* No Operation */ } @@ -67,7 +67,7 @@ namespace API.Benchmark foreach (var name in _names) { - if (IsEpub.IsMatch((name + ".epub"))) + if (Path.GetExtension(name).Equals(".epub", StringComparison.InvariantCultureIgnoreCase)) { /* No Operation */ } diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index 84dee1b8b..e57f56928 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -163,6 +163,7 @@ namespace API.Tests.Parser [InlineData("Citrus+", "citrus+")] [InlineData("Again!!!!", "again")] [InlineData("카비타", "카비타")] + [InlineData("06", "06")] [InlineData("", "")] public void NormalizeTest(string input, string expected) { diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 402f136c3..2cf796e6c 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1650,6 +1650,61 @@ public class ReaderServiceTests Assert.Equal("Some Special Title", nextChapter.Range); } + [Fact] + public async Task GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress() + { + var series = new Series() + { + Name = "Test", + Library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("230", false, new List(), 1), + //EntityFactory.CreateChapter("231", false, new List(), 1), (added later) + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 1), + //EntityFactory.CreateChapter("14.9", false, new List(), 1), (added later) + }), + } + }; + _context.Series.Add(series); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + await readerService.MarkSeriesAsRead(user, 1); + await _context.SaveChangesAsync(); + + // Add 2 new unread series to the Series + series.Volumes[0].Chapters.Add(EntityFactory.CreateChapter("231", false, new List(), 1)); + series.Volumes[2].Chapters.Add(EntityFactory.CreateChapter("14.9", false, new List(), 1)); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var nextChapter = await readerService.GetContinuePoint(1, 1); + Assert.Equal("14.9", nextChapter.Range); + } + #endregion #region MarkChaptersUntilAsRead @@ -1855,5 +1910,126 @@ public class ReaderServiceTests #endregion + #region MarkSeriesAsRead + [Fact] + public async Task MarkSeriesAsReadTest() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + }, + new Chapter() + { + Pages = 2 + } + } + }, + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + }, + new Chapter() + { + Pages = 2 + } + } + } + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + await readerService.MarkSeriesAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); + await _context.SaveChangesAsync(); + + Assert.Equal(4, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + } + + + #endregion + + #region MarkSeriesAsUnread + + [Fact] + public async Task MarkSeriesAsUnreadTest() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + }, + new Chapter() + { + Pages = 2 + } + } + } + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); + readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + + await _context.SaveChangesAsync(); + Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + + await readerService.MarkSeriesAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); + await _context.SaveChangesAsync(); + + var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; + Assert.Equal(0, progresses.Max(p => p.PagesRead)); + Assert.Equal(2, progresses.Count); + } + + #endregion } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 913f53133..7fc8769e7 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -338,6 +338,12 @@ namespace API.Controllers + /// + /// Invites a user to the server. Will generate a setup link for continuing setup. If the server is not accessible, no + /// email will be sent. + /// + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("invite")] public async Task> InviteUser(InviteUserDto dto) @@ -417,7 +423,9 @@ namespace API.Controllers var emailLink = GenerateEmailLink(token, "confirm-email", dto.Email); _logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink); - if (dto.SendEmail) + var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); + var accessible = await _emailService.CheckIfAccessible(host); + if (accessible) { await _emailService.SendConfirmationEmail(new ConfirmationEmailDto() { @@ -426,7 +434,11 @@ namespace API.Controllers ServerConfirmationLink = emailLink }); } - return Ok(emailLink); + return Ok(new InviteUserResponse + { + EmailLink = emailLink, + EmailSent = accessible + }); } catch (Exception) { diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index 5ae577f5e..cbf491c02 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -65,6 +65,8 @@ namespace API.Controllers return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath))); } + + [Authorize(Policy="RequireDownloadRole")] [HttpGet("volume")] public async Task DownloadVolume(int volumeId) diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 3084ab352..a5c2ae676 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -11,6 +11,7 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Services; +using API.SignalR; using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -26,16 +27,18 @@ namespace API.Controllers private readonly IMapper _mapper; private readonly ITaskScheduler _taskScheduler; private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; public LibraryController(IDirectoryService directoryService, ILogger logger, IMapper mapper, ITaskScheduler taskScheduler, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, IEventHub eventHub) { _directoryService = directoryService; _logger = logger; _mapper = mapper; _taskScheduler = taskScheduler; _unitOfWork = unitOfWork; + _eventHub = eventHub; } /// @@ -73,6 +76,8 @@ namespace API.Controllers _logger.LogInformation("Created a new library: {LibraryName}", library.Name); _taskScheduler.ScanLibrary(library.Id); + await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, + MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); return Ok(); } @@ -191,6 +196,9 @@ namespace API.Controllers await _unitOfWork.CommitAsync(); _taskScheduler.CleanupChapters(chapterIds); } + + await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, + MessageFactory.LibraryModifiedEvent(libraryId, "delete"), false); return Ok(true); } catch (Exception ex) diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index b81075c6a..5cef859a4 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -745,9 +745,18 @@ public class OpdsController : BaseApiController var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath); var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty); var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, await GetUser(apiKey)); - - var title = $"{series.Name} - {SeriesService.FormatChapterTitle(chapter, libraryType)}"; + var title = $"{series.Name} - "; + if (volume.Chapters.Count == 1) + { + SeriesService.RenameVolumeName(volume.Chapters.First(), volume, libraryType); + title += $"{volume.Name}"; + } + else + { + title = $"{series.Name} - {SeriesService.FormatChapterTitle(chapter, libraryType)}"; + } // Chunky requires a file at the end. Our API ignores this var accLink = diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 9bcce9591..e2d067abd 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -109,14 +109,7 @@ namespace API.Controllers public async Task MarkRead(MarkReadDto markReadDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId); - user.Progresses ??= new List(); - foreach (var volume in volumes) - { - _readerService.MarkChaptersAsRead(user, markReadDto.SeriesId, volume.Chapters); - } - - _unitOfWork.UserRepository.Update(user); + await _readerService.MarkSeriesAsRead(user, markReadDto.SeriesId); if (await _unitOfWork.CommitAsync()) { @@ -137,14 +130,7 @@ namespace API.Controllers public async Task MarkUnread(MarkReadDto markReadDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId); - user.Progresses ??= new List(); - foreach (var volume in volumes) - { - _readerService.MarkChaptersAsUnread(user, markReadDto.SeriesId, volume.Chapters); - } - - _unitOfWork.UserRepository.Update(user); + await _readerService.MarkSeriesAsUnread(user, markReadDto.SeriesId); if (await _unitOfWork.CommitAsync()) { diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 225d6be9a..3dd23db1a 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -46,7 +46,7 @@ namespace API.Controllers public async Task> GetImageFromFile(UploadUrlDto dto) { var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); - var format = _directoryService.FileSystem.Path.GetExtension(dto.Url).Replace(".", ""); + var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", ""); var path = await dto.Url .DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}"); diff --git a/API/DTOs/Account/InviteUserDto.cs b/API/DTOs/Account/InviteUserDto.cs index 04c9c1103..42d4bdf8e 100644 --- a/API/DTOs/Account/InviteUserDto.cs +++ b/API/DTOs/Account/InviteUserDto.cs @@ -16,6 +16,4 @@ public class InviteUserDto /// A list of libraries to grant access to /// public IList Libraries { get; init; } - - public bool SendEmail { get; init; } = true; } diff --git a/API/DTOs/Account/InviteUserResponse.cs b/API/DTOs/Account/InviteUserResponse.cs new file mode 100644 index 000000000..9387b5492 --- /dev/null +++ b/API/DTOs/Account/InviteUserResponse.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Account; + +public class InviteUserResponse +{ + /// + /// Email link used to setup the user account + /// + public string EmailLink { get; set; } + /// + /// Was an email sent (ie is this server accessible) + /// + public bool EmailSent { get; set; } +} diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs index fba9a7493..1a8d9fc8b 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Runtime.InteropServices; using API.Entities; using API.Entities.Enums; @@ -25,51 +26,51 @@ namespace API.DTOs.Filtering /// public IList Genres { get; init; } = new List(); /// - /// A list of Writers to restrict search to. Defaults to all genres by passing an empty list + /// A list of Writers to restrict search to. Defaults to all Writers by passing an empty list /// public IList Writers { get; init; } = new List(); /// - /// A list of Penciller ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Penciller ids to restrict search to. Defaults to all Pencillers by passing an empty list /// public IList Penciller { get; init; } = new List(); /// - /// A list of Inker ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Inker ids to restrict search to. Defaults to all Inkers by passing an empty list /// public IList Inker { get; init; } = new List(); /// - /// A list of Colorist ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Colorist ids to restrict search to. Defaults to all Colorists by passing an empty list /// public IList Colorist { get; init; } = new List(); /// - /// A list of Letterer ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Letterer ids to restrict search to. Defaults to all Letterers by passing an empty list /// public IList Letterer { get; init; } = new List(); /// - /// A list of CoverArtist ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of CoverArtist ids to restrict search to. Defaults to all CoverArtists by passing an empty list /// public IList CoverArtist { get; init; } = new List(); /// - /// A list of Editor ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Editor ids to restrict search to. Defaults to all Editors by passing an empty list /// public IList Editor { get; init; } = new List(); /// - /// A list of Publisher ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Publisher ids to restrict search to. Defaults to all Publishers by passing an empty list /// public IList Publisher { get; init; } = new List(); /// - /// A list of Character ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Character ids to restrict search to. Defaults to all Characters by passing an empty list /// public IList Character { get; init; } = new List(); /// - /// A list of Translator ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Translator ids to restrict search to. Defaults to all Translatorss by passing an empty list /// public IList Translators { get; init; } = new List(); /// - /// A list of Collection Tag ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Collection Tag ids to restrict search to. Defaults to all Collection Tags by passing an empty list /// public IList CollectionTags { get; init; } = new List(); /// - /// A list of Tag ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Tag ids to restrict search to. Defaults to all Tags by passing an empty list /// public IList Tags { get; init; } = new List(); /// @@ -94,5 +95,10 @@ namespace API.DTOs.Filtering /// public IList PublicationStatus { get; init; } = new List(); + /// + /// An optional name string to filter by. Empty string will ignore. + /// + public string SeriesNameQuery { get; init; } = string.Empty; + } } diff --git a/API/DTOs/Reader/ChapterInfoDto.cs b/API/DTOs/Reader/ChapterInfoDto.cs index 7c847d926..9f33bada7 100644 --- a/API/DTOs/Reader/ChapterInfoDto.cs +++ b/API/DTOs/Reader/ChapterInfoDto.cs @@ -12,6 +12,7 @@ namespace API.DTOs.Reader public MangaFormat SeriesFormat { get; set; } public int SeriesId { get; set; } public int LibraryId { get; set; } + public LibraryType LibraryType { get; set; } public string ChapterTitle { get; set; } = string.Empty; public int Pages { get; set; } public string FileName { get; set; } diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 330aa4b5e..ab3684fa0 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -81,7 +81,8 @@ public class ChapterRepository : IChapterRepository data.TitleName, SeriesFormat = series.Format, SeriesName = series.Name, - series.LibraryId + series.LibraryId, + LibraryType = series.Library.Type }) .Select(data => new ChapterInfoDto() { @@ -89,12 +90,13 @@ public class ChapterRepository : IChapterRepository VolumeNumber = data.VolumeNumber + string.Empty, VolumeId = data.VolumeId, IsSpecial = data.IsSpecial, - SeriesId =data.SeriesId, + SeriesId = data.SeriesId, SeriesFormat = data.SeriesFormat, SeriesName = data.SeriesName, LibraryId = data.LibraryId, Pages = data.Pages, - ChapterTitle = data.TitleName + ChapterTitle = data.TitleName, + LibraryType = data.LibraryType }) .AsNoTracking() .AsSplitQuery() diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index efe2f1a27..16845ad59 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Data.Scanner; using API.DTOs; @@ -79,8 +80,8 @@ public interface ISeriesRepository /// Task AddSeriesModifiers(int userId, List series); Task GetSeriesCoverImageAsync(int seriesId); - Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter); - Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo + Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true); + Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); Task GetSeriesMetadata(int seriesId); Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); Task> GetFilesForSeries(int seriesId); @@ -283,17 +284,22 @@ public class SeriesRepository : ISeriesRepository result.Libraries = await _context.Library .Where(l => libraryIds.Contains(l.Id)) - .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")) + .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) .OrderBy(l => l.Name) .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); + var justYear = Regex.Match(searchQuery, @"\d{4}").Value; + var hasYearInQuery = !string.IsNullOrEmpty(justYear); + var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0; + result.Series = await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%") || EF.Functions.Like(s.OriginalName, $"%{searchQuery}%") - || EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%")) + || EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%") + || (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison)) .Include(s => s.Library) .OrderBy(s => s.SortName) .AsNoTracking() @@ -301,6 +307,7 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); + result.ReadingLists = await _context.ReadingList .Where(rl => rl.AppUserId == userId || rl.Promoted) .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%")) @@ -466,10 +473,16 @@ public class SeriesRepository : ISeriesRepository { s.PagesRead = userProgress.Where(p => p.SeriesId == s.Id).Sum(p => p.PagesRead); var rating = userRatings.SingleOrDefault(r => r.SeriesId == s.Id); - if (rating == null) continue; - s.UserRating = rating.Rating; - s.UserReview = rating.Review; - s.LatestReadDate = userProgress.Max(p => p.LastModified); + if (rating != null) + { + s.UserRating = rating.Rating; + s.UserReview = rating.Review; + } + + if (userProgress.Count > 0) + { + s.LatestReadDate = userProgress.Max(p => p.LastModified); + } } } @@ -508,7 +521,7 @@ public class SeriesRepository : ISeriesRepository private IList ExtractFilters(int libraryId, int userId, FilterDto filter, ref List userLibraries, out List allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter, out bool hasRatingFilter, out bool hasProgressFilter, out IList seriesIds, out bool hasAgeRating, out bool hasTagsFilter, - out bool hasLanguageFilter, out bool hasPublicationFilter) + out bool hasLanguageFilter, out bool hasPublicationFilter, out bool hasSeriesNameFilter) { var formats = filter.GetSqlFilter(); @@ -581,6 +594,8 @@ public class SeriesRepository : ISeriesRepository .ToList(); } + hasSeriesNameFilter = !string.IsNullOrEmpty(filter.SeriesNameQuery); + return formats; } @@ -593,11 +608,11 @@ public class SeriesRepository : ISeriesRepository /// Pagination information /// Optional (default null) filter on query /// - public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter) + public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true) { //var allSeriesWithProgress = await _context.AppUserProgresses.Select(p => p.SeriesId).ToListAsync(); //var allChapters = await GetChapterIdsForSeriesAsync(allSeriesWithProgress); - var cuttoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30); + var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30); var query = (await CreateFilteredSearchQueryable(userId, libraryId, filter)) .Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => new @@ -612,8 +627,12 @@ public class SeriesRepository : ISeriesRepository // This is only taking into account chapters that have progress on them, not all chapters in said series LastChapterCreated = _context.Chapter.Where(c => progress.ChapterId == c.Id).Max(c => c.Created) //LastChapterCreated = _context.Chapter.Where(c => allChapters.Contains(c.Id)).Max(c => c.Created) - }) - .Where(d => d.LastReadingProgress >= cuttoffProgressPoint); + }); + if (cutoffOnDate) + { + query = query.Where(d => d.LastReadingProgress >= cutoffProgressPoint); + } + // I think I need another Join statement. The problem is the chapters are still limited to progress @@ -638,7 +657,7 @@ public class SeriesRepository : ISeriesRepository var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries, out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter, out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter, - out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter); + out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter, out var hasSeriesNameFilter); var query = _context.Series .Where(s => userLibraries.Contains(s.LibraryId) @@ -652,8 +671,11 @@ public class SeriesRepository : ISeriesRepository && (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating)) && (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id))) && (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language)) - && (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus)) - ) + && (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus))) + .Where(s => !hasSeriesNameFilter || + EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%") + || EF.Functions.Like(s.OriginalName, $"%{filter.SeriesNameQuery}%") + || EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%")) .AsNoTracking(); // If no sort options, default to using SortName diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index e41849c92..60c72a77c 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -220,7 +220,8 @@ public class UserRepository : IUserRepository public async Task GetUserByEmailAsync(string email) { - return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.ToLower().Equals(email.ToLower())); + var lowerEmail = email.ToLower(); + return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.ToLower().Equals(lowerEmail)); } public async Task> GetAllUsers() diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 1b8557f73..cc237eae7 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -57,7 +57,7 @@ namespace API.Parser private static readonly Regex CoverImageRegex = new Regex(@"(? @@ -1009,12 +1008,12 @@ namespace API.Parser public static bool IsEpub(string filePath) { - return Path.GetExtension(filePath).ToLower() == ".epub"; + return Path.GetExtension(filePath).Equals(".epub", StringComparison.InvariantCultureIgnoreCase); } public static bool IsPdf(string filePath) { - return Path.GetExtension(filePath).ToLower() == ".pdf"; + return Path.GetExtension(filePath).Equals(".pdf", StringComparison.InvariantCultureIgnoreCase); } /// @@ -1025,8 +1024,7 @@ namespace API.Parser /// public static string CleanAuthor(string author) { - if (string.IsNullOrEmpty(author)) return string.Empty; - return author.Trim(); + return string.IsNullOrEmpty(author) ? string.Empty : author.Trim(); } /// diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index ab2c52a2c..c5ba90464 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -98,7 +98,7 @@ public class EmailService : IEmailService return await SendEmailWithPost(emailLink + "/api/email/email-password-reset", data); } - private static async Task SendEmailWithGet(string url) + private static async Task SendEmailWithGet(string url, int timeoutSecs = 30) { try { @@ -108,7 +108,7 @@ public class EmailService : IEmailService .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") .WithHeader("x-kavita-version", BuildInfo.Version) .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(30)) + .WithTimeout(TimeSpan.FromSeconds(timeoutSecs)) .GetStringAsync(); if (!string.IsNullOrEmpty(response) && bool.Parse(response)) @@ -124,7 +124,7 @@ public class EmailService : IEmailService } - private static async Task SendEmailWithPost(string url, object data) + private static async Task SendEmailWithPost(string url, object data, int timeoutSecs = 30) { try { @@ -134,7 +134,7 @@ public class EmailService : IEmailService .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") .WithHeader("x-kavita-version", BuildInfo.Version) .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(30)) + .WithTimeout(TimeSpan.FromSeconds(timeoutSecs)) .PostJsonAsync(data); if (response.StatusCode != StatusCodes.Status200OK) diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index f5437e7bc..0c989900e 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -16,6 +16,8 @@ namespace API.Services; public interface IReaderService { + Task MarkSeriesAsRead(AppUser user, int seriesId); + Task MarkSeriesAsUnread(AppUser user, int seriesId); void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters); void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters); Task SaveReadingProgress(ProgressDto progressDto, int userId); @@ -45,6 +47,40 @@ public class ReaderService : IReaderService return Parser.Parser.NormalizePath(Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}")); } + /// + /// Does not commit. Marks all entities under the series as read. + /// + /// + /// + public async Task MarkSeriesAsRead(AppUser user, int seriesId) + { + var volumes = await _unitOfWork.VolumeRepository.GetVolumes(seriesId); + user.Progresses ??= new List(); + foreach (var volume in volumes) + { + MarkChaptersAsRead(user, seriesId, volume.Chapters); + } + + _unitOfWork.UserRepository.Update(user); + } + + /// + /// Does not commit. Marks all entities under the series as unread. + /// + /// + /// + public async Task MarkSeriesAsUnread(AppUser user, int seriesId) + { + var volumes = await _unitOfWork.VolumeRepository.GetVolumes(seriesId); + user.Progresses ??= new List(); + foreach (var volume in volumes) + { + MarkChaptersAsUnread(user, seriesId, volume.Chapters); + } + + _unitOfWork.UserRepository.Update(user); + } + /// /// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit. /// @@ -367,7 +403,7 @@ public class ReaderService : IReaderService .ToList(); // If there are any volumes that have progress, return those. If not, move on. - var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages && chapter.PagesRead > 0); + var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages); // (removed for GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress), not sure if needed && chapter.PagesRead > 0 if (currentlyReadingChapter != null) return currentlyReadingChapter; // Check loose leaf chapters (and specials). First check if there are any diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 14969884f..3f82af55d 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -445,20 +445,7 @@ public class SeriesService : ISeriesService var firstChapter = volume.Chapters.First(); // On Books, skip volumes that are specials, since these will be shown if (firstChapter.IsSpecial) continue; - if (string.IsNullOrEmpty(firstChapter.TitleName)) - { - if (!firstChapter.Range.Equals(Parser.Parser.DefaultVolume)) - { - var title = Path.GetFileNameWithoutExtension(firstChapter.Range); - if (string.IsNullOrEmpty(title)) continue; - volume.Name += $" - {title}"; - } - } - else - { - volume.Name += $" - {firstChapter.TitleName}"; - } - + RenameVolumeName(firstChapter, volume, libraryType); processedVolumes.Add(volume); } } @@ -517,48 +504,64 @@ public class SeriesService : ISeriesService return !c.IsSpecial && !c.Number.Equals(Parser.Parser.DefaultChapter); } - public static string FormatChapterTitle(ChapterDto chapter, LibraryType libraryType) + public static void RenameVolumeName(ChapterDto firstChapter, VolumeDto volume, LibraryType libraryType) { - if (chapter.IsSpecial) + if (libraryType == LibraryType.Book) { - return Parser.Parser.CleanSpecialTitle(chapter.Title); + if (string.IsNullOrEmpty(firstChapter.TitleName)) + { + if (firstChapter.Range.Equals(Parser.Parser.DefaultVolume)) return; + var title = Path.GetFileNameWithoutExtension(firstChapter.Range); + if (string.IsNullOrEmpty(title)) return; + volume.Name += $" - {title}"; + } + else + { + volume.Name += $" - {firstChapter.TitleName}"; + } + + return; } + + volume.Name = $"Volume {volume.Name}"; + } + + + private static string FormatChapterTitle(bool isSpecial, LibraryType libraryType, string chapterTitle, bool withHash) + { + if (isSpecial) + { + return Parser.Parser.CleanSpecialTitle(chapterTitle); + } + + var hashSpot = withHash ? "#" : string.Empty; return libraryType switch { - LibraryType.Book => $"Book {chapter.Title}", - LibraryType.Comic => $"Issue #{chapter.Title}", - LibraryType.Manga => $"Chapter {chapter.Title}", + LibraryType.Book => $"Book {chapterTitle}", + LibraryType.Comic => $"Issue {hashSpot}{chapterTitle}", + LibraryType.Manga => $"Chapter {chapterTitle}", _ => "Chapter " }; } - public static string FormatChapterTitle(Chapter chapter, LibraryType libraryType) + public static string FormatChapterTitle(ChapterDto chapter, LibraryType libraryType, bool withHash = true) { - if (chapter.IsSpecial) - { - return Parser.Parser.CleanSpecialTitle(chapter.Title); - } - return libraryType switch - { - LibraryType.Book => $"Book {chapter.Title}", - LibraryType.Comic => $"Issue #{chapter.Title}", - LibraryType.Manga => $"Chapter {chapter.Title}", - _ => "Chapter " - }; + return FormatChapterTitle(chapter.IsSpecial, libraryType, chapter.Title, withHash); + } + + public static string FormatChapterTitle(Chapter chapter, LibraryType libraryType, bool withHash = true) + { + return FormatChapterTitle(chapter.IsSpecial, libraryType, chapter.Title, withHash); } public static string FormatChapterName(LibraryType libraryType, bool withHash = false) { - switch (libraryType) + return libraryType switch { - case LibraryType.Manga: - return "Chapter"; - case LibraryType.Comic: - return withHash ? "Issue #" : "Issue"; - case LibraryType.Book: - return "Book"; - default: - throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null); - } + LibraryType.Manga => "Chapter", + LibraryType.Comic => withHash ? "Issue #" : "Issue", + LibraryType.Book => "Book", + _ => "Chapter" + }; } } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index f0bc2f2a8..6fc6560c2 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -74,6 +74,10 @@ namespace API.SignalR /// When DB updates are occuring during a library/series scan /// private const string ScanProgress = "ScanProgress"; + /// + /// When a library is created/deleted in the Server + /// + public const string LibraryModified = "LibraryModified"; public static SignalRMessage ScanSeriesEvent(int seriesId, string seriesName) @@ -225,6 +229,22 @@ namespace API.SignalR }; } + public static SignalRMessage LibraryModifiedEvent(int libraryId, string action) + { + return new SignalRMessage + { + Name = LibraryModified, + Title = "Library modified", + Progress = ProgressType.None, + EventType = ProgressEventType.Single, + Body = new + { + LibrayId = libraryId, + Action = action, + } + }; + } + public static SignalRMessage DownloadProgressEvent(string username, string downloadName, float progress, string eventType = "updated") { return new SignalRMessage() @@ -270,27 +290,8 @@ namespace API.SignalR }; } - public static SignalRMessage DbUpdateProgressEvent(Series series, string eventType) - { - // TODO: I want this as a detail of a Scanning Series and we can put more information like Volume or Chapter here - return new SignalRMessage() - { - Name = ScanProgress, - Title = $"Scanning {series.Library.Name}", - SubTitle = series.Name, - EventType = eventType, - Progress = ProgressType.Indeterminate, - Body = new - { - Title = "Updating Series", - SubTitle = series.Name - } - }; - } - public static SignalRMessage LibraryScanProgressEvent(string libraryName, string eventType, string seriesName = "") { - // TODO: I want this as a detail of a Scanning Series and we can put more information like Volume or Chapter here return new SignalRMessage() { Name = ScanProgress, diff --git a/UI/Web/src/app/_models/events/library-modified-event.ts b/UI/Web/src/app/_models/events/library-modified-event.ts new file mode 100644 index 000000000..d09055401 --- /dev/null +++ b/UI/Web/src/app/_models/events/library-modified-event.ts @@ -0,0 +1,4 @@ +export interface LibraryModifiedEvent { + libraryId: number; + action: 'create' | 'delelte'; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/invite-user-response.ts b/UI/Web/src/app/_models/invite-user-response.ts new file mode 100644 index 000000000..a9042c555 --- /dev/null +++ b/UI/Web/src/app/_models/invite-user-response.ts @@ -0,0 +1,10 @@ +export interface InviteUserResponse { + /** + * Link to register new user + */ + emailLink: string; + /** + * If an email was sent to the invited user + */ + emailSent: boolean; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/series-filter.ts b/UI/Web/src/app/_models/series-filter.ts index a370266f5..aeafb3331 100644 --- a/UI/Web/src/app/_models/series-filter.ts +++ b/UI/Web/src/app/_models/series-filter.ts @@ -29,6 +29,7 @@ export interface SeriesFilter { tags: Array; languages: Array; publicationStatus: Array; + seriesNameQuery: string; } export interface SortOptions { diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 419dec77d..3057704cb 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -8,6 +8,7 @@ import { User } from '../_models/user'; import { Router } from '@angular/router'; import { MessageHubService } from './message-hub.service'; import { ThemeService } from '../theme.service'; +import { InviteUserResponse } from '../_models/invite-user-response'; @Injectable({ providedIn: 'root' @@ -130,8 +131,8 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'}); } - inviteUser(model: {email: string, roles: Array, libraries: Array, sendEmail: boolean}) { - return this.httpClient.post(this.baseUrl + 'account/invite', model, {responseType: 'text' as 'json'}); + inviteUser(model: {email: string, roles: Array, libraries: Array}) { + return this.httpClient.post(this.baseUrl + 'account/invite', model); } confirmEmail(model: {email: string, username: string, password: string, token: string}) { diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index bf79902f5..f53b9681f 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -4,6 +4,7 @@ import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; import { ToastrService } from 'ngx-toastr'; import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { environment } from 'src/environments/environment'; +import { LibraryModifiedEvent } from '../_models/events/library-modified-event'; import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; import { SiteThemeProgressEvent } from '../_models/events/site-theme-progress-event'; import { User } from '../_models/user'; @@ -49,6 +50,10 @@ export enum EVENTS { * A subtype of NotificationProgress that represents a file being processed for cover image extraction */ CoverUpdateProgress = 'CoverUpdateProgress', + /** + * A library is created or removed from the instance + */ + LibraryModified = 'LibraryModified' } export interface Message { @@ -130,6 +135,13 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.LibraryModified, resp => { + this.messagesSource.next({ + event: EVENTS.LibraryModified, + payload: resp.body as LibraryModifiedEvent + }); + }); + this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => { this.messagesSource.next({ diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 1b7c91405..ed6be1b89 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -226,6 +226,7 @@ export class SeriesService { tags: [], languages: [], publicationStatus: [], + seriesNameQuery: '', }; if (filter === undefined) return data; diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts index 0f042a097..a76eefdf5 100644 --- a/UI/Web/src/app/admin/admin.module.ts +++ b/UI/Web/src/app/admin/admin.module.ts @@ -13,12 +13,12 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ResetPasswordModalComponent } from './_modals/reset-password-modal/reset-password-modal.component'; import { ManageSettingsComponent } from './manage-settings/manage-settings.component'; import { ManageSystemComponent } from './manage-system/manage-system.component'; -import { ChangelogComponent } from '../announcements/changelog/changelog.component'; import { PipeModule } from '../pipe/pipe.module'; import { InviteUserComponent } from './invite-user/invite-user.component'; import { RoleSelectorComponent } from './role-selector/role-selector.component'; import { LibrarySelectorComponent } from './library-selector/library-selector.component'; import { EditUserComponent } from './edit-user/edit-user.component'; +import { UserSettingsModule } from '../user-settings/user-settings.module'; import { SidenavModule } from '../sidenav/sidenav.module'; @@ -50,7 +50,8 @@ import { SidenavModule } from '../sidenav/sidenav.module'; NgbDropdownModule, SharedModule, PipeModule, - SidenavModule + SidenavModule, + UserSettingsModule // API-key componet ], providers: [] }) diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.html b/UI/Web/src/app/admin/invite-user/invite-user.component.html index 83fbb86eb..495b3f6e9 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.html +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.html @@ -9,13 +9,7 @@ Invite a user to your server. Enter their email in and we will send them an email to create an account.

-

- -  Checking accessibility of server... -

- - -
+
@@ -28,11 +22,6 @@
- -

Use this link to finish setting up the user account due to your server not being accessible outside your local network.

- -
-
@@ -44,12 +33,21 @@
+ +

User invited

+

You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user. + If your server is externallyaccessible, an email will have been sent to the user and the links can be used by them to finish setting up their account. +

+ + +
+
@@ -106,24 +106,26 @@

{{utilityService.formatChapterName(libraryType) + 's'}}

  • - +
    - + - {{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}} + + {{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}} + - + {{chapter.pagesRead}} / {{chapter.pages}} UNREAD READ - File(s) + Files
    • diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index 2f319a803..4acda1413 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -89,7 +89,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { private seriesService: SeriesService, public utilityService: UtilityService, private fb: FormBuilder, - public imageService: ImageService, + public imageService: ImageService, private libraryService: LibraryService, private collectionService: CollectionTagService, private uploadService: UploadService, @@ -98,8 +98,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { ngOnInit(): void { this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id)); - this.initSeries = Object.assign({}, this.series); - this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => { this.libraryName = names[this.series.libraryId]; }); @@ -107,7 +105,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.editSeriesForm = this.fb.group({ id: new FormControl(this.series.id, []), - summary: new FormControl('', []), + summary: new FormControl('', []), name: new FormControl(this.series.name, []), localizedName: new FormControl(this.series.localizedName, []), sortName: new FormControl(this.series.sortName, []), @@ -125,7 +123,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.metadataService.getAllAgeRatings().subscribe(ratings => { this.ageRatings = ratings; }); - + this.metadataService.getAllPublicationStatus().subscribe(statuses => { this.publicationStatuses = statuses; }); @@ -166,7 +164,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.metadata.ageRating = parseInt(val + '', 10); this.metadata.ageRatingLocked = true; }); - + this.editSeriesForm.get('publicationStatus')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { this.metadata.publicationStatus = parseInt(val + '', 10); this.metadata.publicationStatusLocked = true; @@ -245,8 +243,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { return options.filter(m => this.utilityService.filter(m.title, filter)); } this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags() - .pipe(map(items => this.tagsSettings.compareFn(items, filter))); - + .pipe(map(items => this.tagsSettings.compareFn(items, filter))); + this.tagsSettings.addTransformFn = ((title: string) => { return {id: 0, title: title }; }); @@ -269,7 +267,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.genreSettings.addIfNonExisting = true; this.genreSettings.fetchFn = (filter: string) => { return this.metadataService.getAllGenres() - .pipe(map(items => this.genreSettings.compareFn(items, filter))); + .pipe(map(items => this.genreSettings.compareFn(items, filter))); }; this.genreSettings.compareFn = (options: Genre[], filter: string) => { return options.filter(m => this.utilityService.filter(m.title, filter)); @@ -336,7 +334,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { return forkJoin([ this.updateFromPreset('writer', this.metadata.writers, PersonRole.Writer), - this.updateFromPreset('character', this.metadata.characters, PersonRole.Character), + this.updateFromPreset('character', this.metadata.characters, PersonRole.Character), this.updateFromPreset('colorist', this.metadata.colorists, PersonRole.Colorist), this.updateFromPreset('cover-artist', this.metadata.coverArtists, PersonRole.CoverArtist), this.updateFromPreset('editor', this.metadata.editors, PersonRole.Editor), @@ -350,7 +348,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { })); } - fetchPeople(role: PersonRole, filter: string) { + fetchPeople(role: PersonRole, filter: string) { return this.metadataService.getAllPeople().pipe(map(people => { return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter)); })); @@ -415,7 +413,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { apis.push(this.seriesService.updateSeries(model)); } - + if (selectedIndex > 0 && this.selectedCover !== '') { apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover)); } diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html index 6de989b21..7487d71fa 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html @@ -14,7 +14,7 @@
    - + @@ -22,7 +22,7 @@
    - +

    There is no data

    @@ -68,7 +68,7 @@
- +
diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index e6f5c2e89..9165d215d 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -20,7 +20,7 @@ const ANIMATION_SPEED = 300; export class CardDetailLayoutComponent implements OnInit, OnDestroy { @Input() header: string = ''; - @Input() isLoading: boolean = false; + @Input() isLoading: boolean = false; @Input() items: any[] = []; @Input() pagination!: Pagination; /** @@ -36,7 +36,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { @Output() itemClicked: EventEmitter = new EventEmitter(); @Output() pageChange: EventEmitter = new EventEmitter(); @Output() applyFilter: EventEmitter = new EventEmitter(); - + @ContentChild('cardItem') itemTemplate!: TemplateRef; diff --git a/UI/Web/src/app/carousel/carousel-reel/carousel-reel.component.html b/UI/Web/src/app/carousel/carousel-reel/carousel-reel.component.html index 98d466c35..0857e8eff 100644 --- a/UI/Web/src/app/carousel/carousel-reel/carousel-reel.component.html +++ b/UI/Web/src/app/carousel/carousel-reel/carousel-reel.component.html @@ -1,6 +1,6 @@