diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 09e211d51..1592a13d7 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -247,7 +247,6 @@ public class AccountController : BaseApiController [HttpGet("roles")] public ActionResult> GetRoles() { - // TODO: This should be moved to ServerController return typeof(PolicyConstants) .GetFields(BindingFlags.Public | BindingFlags.Static) .Where(f => f.FieldType == typeof(string)) diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 96c27ede7..12b116cb8 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -41,6 +41,22 @@ public class ImageController : BaseApiController return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } + /// + /// Returns cover image for Library + /// + /// + /// + [HttpGet("library-cover")] + [ResponseCache(CacheProfileName = "Images")] + public async Task GetLibraryCoverImage(int libraryId) + { + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.LibraryRepository.GetLibraryCoverImageAsync(libraryId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); + + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); + } + /// /// Returns cover image for Volume /// diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 202d6b2cb..394fd7fa3 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -295,35 +295,62 @@ public class LibraryController : BaseApiController } } + /// + /// Checks if the library name exists or not + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("name-exists")] + public async Task> IsLibraryNameValid(string name) + { + return Ok(await _unitOfWork.LibraryRepository.LibraryExists(name.Trim())); + } + /// /// Updates an existing Library with new name, folders, and/or type. /// /// Any folder or type change will invoke a scan. - /// + /// /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("update")] - public async Task UpdateLibrary(UpdateLibraryDto libraryForUserDto) + public async Task UpdateLibrary(UpdateLibraryDto dto) { - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryForUserDto.Id, LibraryIncludes.Folders); + var newName = dto.Name.Trim(); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.Id, LibraryIncludes.Folders); + if (await _unitOfWork.LibraryRepository.LibraryExists(newName) && !library.Name.Equals(newName)) + return BadRequest("Library name already exists"); var originalFolders = library.Folders.Select(x => x.Path).ToList(); - library.Name = libraryForUserDto.Name; - library.Folders = libraryForUserDto.Folders.Select(s => new FolderPath() {Path = s}).ToList(); + library.Name = newName; + library.Folders = dto.Folders.Select(s => new FolderPath() {Path = s}).ToList(); - var typeUpdate = library.Type != libraryForUserDto.Type; - library.Type = libraryForUserDto.Type; + var typeUpdate = library.Type != dto.Type; + var folderWatchingUpdate = library.FolderWatching != dto.FolderWatching; + library.Type = dto.Type; + library.FolderWatching = dto.FolderWatching; + library.IncludeInDashboard = dto.IncludeInDashboard; + library.IncludeInRecommended = dto.IncludeInRecommended; + library.IncludeInSearch = dto.IncludeInSearch; _unitOfWork.LibraryRepository.Update(library); if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue updating the library."); - if (originalFolders.Count != libraryForUserDto.Folders.Count() || typeUpdate) + if (originalFolders.Count != dto.Folders.Count() || typeUpdate) { await _libraryWatcher.RestartWatching(); _taskScheduler.ScanLibrary(library.Id); } + if (folderWatchingUpdate) + { + await _libraryWatcher.RestartWatching(); + } + await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, + MessageFactory.LibraryModifiedEvent(library.Id, "update"), false); + return Ok(); } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 73631e67c..cd2001c08 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -100,7 +100,6 @@ 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(".", ""); diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs index 722a3b310..62ad278c5 100644 --- a/API/Controllers/SearchController.cs +++ b/API/Controllers/SearchController.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; using API.DTOs; using API.DTOs.Search; using API.Extensions; @@ -50,17 +51,16 @@ public class SearchController : BaseApiController [HttpGet("search")] public async Task> Search(string queryString) { - queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty); + queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - // Get libraries user has access to - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); + var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList(); + if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); - if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); - if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString); + var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, + libraries, queryString); return Ok(series); } diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index c19af1956..02727f686 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -179,8 +179,6 @@ public class ServerController : BaseApiController LastExecution = dto.LastExecution, }); - // For now, let's just do something simple - //var enqueuedJobs = JobStorage.Current.GetMonitoringApi().EnqueuedJobs("default", 0, int.MaxValue); return Ok(recurringJobs); } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 68d28e442..dc4986910 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -266,6 +266,63 @@ public class UploadController : BaseApiController return BadRequest("Unable to save cover image to Chapter"); } + /// + /// Replaces library cover image with a base64 encoded image. If empty string passed, will reset to null. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [RequestSizeLimit(8_000_000)] + [HttpPost("library")] + public async Task UploadLibraryCoverImageFromUrl(UploadFileDto uploadFileDto) + { + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(uploadFileDto.Id); + if (library == null) return BadRequest("This library does not exist"); + + // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. + // See if we can do this all in memory without touching underlying system + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + library.CoverImage = null; + _unitOfWork.LibraryRepository.Update(library); + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(library.Id, MessageFactoryEntityTypes.Library), false); + } + + return Ok(); + } + + try + { + var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}"); + + if (!string.IsNullOrEmpty(filePath)) + { + library.CoverImage = filePath; + _unitOfWork.LibraryRepository.Update(library); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(library.Id, MessageFactoryEntityTypes.Library), false); + return Ok(); + } + + } + catch (Exception e) + { + _logger.LogError(e, "There was an issue uploading cover image for Library {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); + } + + return BadRequest("Unable to save cover image to Library"); + } + /// /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. /// diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index 4226acbd7..4a451c52a 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -13,5 +13,25 @@ public class LibraryDto /// public DateTime LastScanned { get; init; } public LibraryType Type { get; init; } + /// + /// An optional Cover Image or null + /// + public string CoverImage { get; init; } + /// + /// If Folder Watching is enabled for this library + /// + public bool FolderWatching { get; set; } = true; + /// + /// Include Library series on Dashboard Streams + /// + public bool IncludeInDashboard { get; set; } = true; + /// + /// Include Library series on Recommended Streams + /// + public bool IncludeInRecommended { get; set; } = true; + /// + /// Include library series in Search + /// + public bool IncludeInSearch { get; set; } = true; public ICollection Folders { get; init; } } diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index 4f527cb60..602351328 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -9,4 +9,9 @@ public class UpdateLibraryDto public string Name { get; init; } public LibraryType Type { get; set; } public IEnumerable Folders { get; init; } + public bool FolderWatching { get; init; } + public bool IncludeInDashboard { get; init; } + public bool IncludeInRecommended { get; init; } + public bool IncludeInSearch { get; init; } + } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 2e681c62d..a5cb6b191 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -89,6 +89,20 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.GlobalPageLayoutMode) .HasDefaultValue(PageLayoutMode.Cards); + + + builder.Entity() + .Property(b => b.FolderWatching) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.IncludeInDashboard) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.IncludeInRecommended) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.IncludeInSearch) + .HasDefaultValue(true); } diff --git a/API/Data/Migrations/20221118131123_ExtendedLibrarySettings.Designer.cs b/API/Data/Migrations/20221118131123_ExtendedLibrarySettings.Designer.cs new file mode 100644 index 000000000..e79dddcbc --- /dev/null +++ b/API/Data/Migrations/20221118131123_ExtendedLibrarySettings.Designer.cs @@ -0,0 +1,1693 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20221118131123_ExtendedLibrarySettings")] + partial class ExtendedLibrarySettings + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.10"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20221118131123_ExtendedLibrarySettings.cs b/API/Data/Migrations/20221118131123_ExtendedLibrarySettings.cs new file mode 100644 index 000000000..1c05b6b5b --- /dev/null +++ b/API/Data/Migrations/20221118131123_ExtendedLibrarySettings.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class ExtendedLibrarySettings : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FolderWatching", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "IncludeInDashboard", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "IncludeInRecommended", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "IncludeInSearch", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FolderWatching", + table: "Library"); + + migrationBuilder.DropColumn( + name: "IncludeInDashboard", + table: "Library"); + + migrationBuilder.DropColumn( + name: "IncludeInRecommended", + table: "Library"); + + migrationBuilder.DropColumn( + name: "IncludeInSearch", + table: "Library"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index c3f5c8e53..68065530b 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -545,6 +545,26 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("LastModified") .HasColumnType("TEXT"); diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index 7a50f365e..39ab5999e 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -9,6 +9,7 @@ using API.DTOs.JumpBar; using API.DTOs.Metadata; using API.Entities; using API.Entities.Enums; +using API.Extensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Kavita.Common.Extensions; @@ -38,7 +39,7 @@ public interface ILibraryRepository Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None); Task DeleteLibrary(int libraryId); Task> GetLibrariesForUserIdAsync(int userId); - Task> GetLibraryIdsForUserIdAsync(int userId); + IEnumerable GetLibraryIdsForUserIdAsync(int userId, QueryContext queryContext = QueryContext.None); Task GetLibraryTypeAsync(int libraryId); Task> GetLibraryForIdsAsync(IEnumerable libraryIds, LibraryIncludes includes = LibraryIncludes.None); Task GetTotalFiles(); @@ -48,7 +49,8 @@ public interface ILibraryRepository Task> GetAllLanguagesForLibrariesAsync(); IEnumerable GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); Task DoAnySeriesFoldersMatch(IEnumerable folders); - Library GetLibraryByFolder(string folder); + Task GetLibraryCoverImageAsync(int libraryId); + Task> GetAllCoverImagesAsync(); } public class LibraryRepository : ILibraryRepository @@ -126,12 +128,13 @@ public class LibraryRepository : ILibraryRepository .ToListAsync(); } - public async Task> GetLibraryIdsForUserIdAsync(int userId) + public IEnumerable GetLibraryIdsForUserIdAsync(int userId, QueryContext queryContext = QueryContext.None) { - return await _context.Library + return _context.Library + .IsRestricted(queryContext) .Where(l => l.AppUsers.Select(ap => ap.Id).Contains(userId)) .Select(l => l.Id) - .ToListAsync(); + .AsEnumerable(); } public async Task GetLibraryTypeAsync(int libraryId) @@ -377,12 +380,21 @@ public class LibraryRepository : ILibraryRepository return await _context.Series.AnyAsync(s => normalized.Contains(s.FolderPath)); } - public Library? GetLibraryByFolder(string folder) + public Task GetLibraryCoverImageAsync(int libraryId) { - var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder); return _context.Library - .Include(l => l.Folders) - .AsSplitQuery() - .SingleOrDefault(l => l.Folders.Select(f => f.Path).Contains(normalized)); + .Where(l => l.Id == libraryId) + .Select(l => l.CoverImage) + .SingleOrDefaultAsync(); + + } + + public async Task> GetAllCoverImagesAsync() + { + return await _context.ReadingList + .Select(t => t.CoverImage) + .Where(t => !string.IsNullOrEmpty(t)) + .AsNoTracking() + .ToListAsync(); } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 97f1eebba..3ad2d93f4 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -37,6 +37,18 @@ public enum SeriesIncludes Library = 16, } +/// +/// For complex queries, Library has certain restrictions where the library should not be included in results. +/// This enum dictates which field to use for the lookup. +/// +public enum QueryContext +{ + None = 1, + Search = 2, + Recommended = 3, + Dashboard = 4, +} + public interface ISeriesRepository { void Add(Series series); @@ -62,7 +74,7 @@ public interface ISeriesRepository /// /// /// - Task SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery); + Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery); Task> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None); Task GetSeriesDtoByIdAsync(int seriesId, int userId); Task GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata); @@ -257,7 +269,7 @@ public class SeriesRepository : ISeriesRepository /// public async Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter) { - var query = await CreateFilteredSearchQueryable(userId, libraryId, filter); + var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.None); var retSeries = query .ProjectTo(_mapper.ConfigurationProvider) @@ -267,13 +279,14 @@ public class SeriesRepository : ISeriesRepository return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); } - private async Task> GetUserLibraries(int libraryId, int userId) + private async Task> GetUserLibrariesForFilteredQuery(int libraryId, int userId, QueryContext queryContext) { if (libraryId == 0) { return await _context.Library .Include(l => l.AppUsers) .Where(library => library.AppUsers.Any(user => user.Id == userId)) + .IsRestricted(queryContext) .AsNoTracking() .AsSplitQuery() .Select(library => library.Id) @@ -286,7 +299,7 @@ public class SeriesRepository : ISeriesRepository }; } - public async Task SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery) + public async Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery) { const int maxRecords = 15; var result = new SearchResultGroupDto(); @@ -302,6 +315,7 @@ public class SeriesRepository : ISeriesRepository result.Libraries = await _context.Library .Where(l => libraryIds.Contains(l.Id)) .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) + .IsRestricted(QueryContext.Search) .OrderBy(l => l.Name) .AsSplitQuery() .Take(maxRecords) @@ -549,7 +563,7 @@ public class SeriesRepository : ISeriesRepository /// public async Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter) { - var query = await CreateFilteredSearchQueryable(userId, libraryId, filter); + var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.Dashboard); var retSeries = query .OrderByDescending(s => s.Created) @@ -658,7 +672,7 @@ public class SeriesRepository : ISeriesRepository var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30); var cutoffLastAddedPoint = DateTime.Now - TimeSpan.FromDays(7); - var libraryIds = GetLibraryIdsForUser(userId, libraryId); + var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); @@ -686,9 +700,9 @@ public class SeriesRepository : ISeriesRepository return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } - private async Task> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter) + private async Task> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, QueryContext queryContext) { - var userLibraries = await GetUserLibraries(libraryId, userId); + var userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, queryContext); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries, @@ -762,7 +776,7 @@ public class SeriesRepository : ISeriesRepository private async Task> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, IQueryable sQuery) { - var userLibraries = await GetUserLibraries(libraryId, userId); + var userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, QueryContext.Search); 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, @@ -1059,7 +1073,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId); + var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); @@ -1086,7 +1100,7 @@ public class SeriesRepository : ISeriesRepository /// public async Task> GetRediscover(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId); + var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithProgress = _context.AppUserProgresses .Where(s => usersSeriesIds.Contains(s.SeriesId)) @@ -1105,7 +1119,7 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesForMangaFile(int mangaFileId, int userId) { - var libraryIds = GetLibraryIdsForUser(userId); + var libraryIds = GetLibraryIdsForUser(userId, 0, QueryContext.Search); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.MangaFile @@ -1264,7 +1278,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetHighlyRated(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId); + var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithHighRating = _context.AppUserRating .Where(s => usersSeriesIds.Contains(s.SeriesId) && s.Rating > 4) @@ -1285,7 +1299,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetQuickReads(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId); + var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithProgress = _context.AppUserProgresses .Where(s => usersSeriesIds.Contains(s.SeriesId)) @@ -1311,7 +1325,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId); + var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithProgress = _context.AppUserProgresses .Where(s => usersSeriesIds.Contains(s.SeriesId)) @@ -1341,21 +1355,27 @@ public class SeriesRepository : ISeriesRepository /// /// /// 0 for no library filter + /// Defaults to None - The context behind this query, so appropriate restrictions can be placed /// - private IQueryable GetLibraryIdsForUser(int userId, int libraryId = 0) + private IQueryable GetLibraryIdsForUser(int userId, int libraryId = 0, QueryContext queryContext = QueryContext.None) { - var query = _context.AppUser + var user = _context.AppUser .AsSplitQuery() .AsNoTracking() - .Where(u => u.Id == userId); + .Where(u => u.Id == userId) + .AsSingleQuery(); if (libraryId == 0) { - return query.SelectMany(l => l.Libraries.Select(lib => lib.Id)); + return user.SelectMany(l => l.Libraries) + .IsRestricted(queryContext) + .Select(lib => lib.Id); } - return query.SelectMany(l => - l.Libraries.Where(lib => lib.Id == libraryId).Select(lib => lib.Id)); + return user.SelectMany(l => l.Libraries) + .Where(lib => lib.Id == libraryId) + .IsRestricted(queryContext) + .Select(lib => lib.Id); } public async Task GetRelatedSeries(int userId, int seriesId) @@ -1430,8 +1450,9 @@ public class SeriesRepository : ISeriesRepository { var libraryIds = await _context.AppUser .Where(u => u.Id == userId) - .SelectMany(u => u.Libraries.Select(l => new {LibraryId = l.Id, LibraryType = l.Type})) - .Select(l => l.LibraryId) + .SelectMany(u => u.Libraries) + .Where(l => l.IncludeInDashboard) + .Select(l => l.Id) .ToListAsync(); var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12); diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index b6fac76f3..84b4fa403 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Linq; using API.Entities.Enums; using API.Entities.Interfaces; @@ -11,12 +9,24 @@ public class Library : IEntityDate { public int Id { get; set; } public string Name { get; set; } - /// - /// This is not used, but planned once we build out a Library detail page - /// - [Obsolete("This has never been coded for. Likely we can remove it.")] public string CoverImage { get; set; } public LibraryType Type { get; set; } + /// + /// If Folder Watching is enabled for this library + /// + public bool FolderWatching { get; set; } = true; + /// + /// Include Library series on Dashboard Streams + /// + public bool IncludeInDashboard { get; set; } = true; + /// + /// Include Library series on Recommended Streams + /// + public bool IncludeInRecommended { get; set; } = true; + /// + /// Include library series in Search + /// + public bool IncludeInSearch { get; set; } = true; public DateTime Created { get; set; } public DateTime LastModified { get; set; } /// @@ -27,4 +37,5 @@ public class Library : IEntityDate public ICollection Folders { get; set; } public ICollection AppUsers { get; set; } public ICollection Series { get; set; } + } diff --git a/API/Extensions/QueryableExtensions.cs b/API/Extensions/QueryableExtensions.cs index cf8c0faa0..3deac20e4 100644 --- a/API/Extensions/QueryableExtensions.cs +++ b/API/Extensions/QueryableExtensions.cs @@ -158,4 +158,32 @@ public static class QueryableExtensions return query.AsSplitQuery(); } + + /// + /// Applies restriction based on if the Library has restrictions (like include in search) + /// + /// + /// + /// + public static IQueryable IsRestricted(this IQueryable query, QueryContext context) + { + if (context.HasFlag(QueryContext.None)) return query; + + if (context.HasFlag(QueryContext.Dashboard)) + { + query = query.Where(l => l.IncludeInDashboard); + } + + if (context.HasFlag(QueryContext.Recommended)) + { + query = query.Where(l => l.IncludeInRecommended); + } + + if (context.HasFlag(QueryContext.Search)) + { + query = query.Where(l => l.IncludeInSearch); + } + + return query; + } } diff --git a/API/Program.cs b/API/Program.cs index 01847faaa..3f2ff8bae 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -86,7 +86,7 @@ public class Program { await MigrateSeriesRelationsExport.Migrate(context, logger); } - catch (Exception ex) + catch (Exception) { // If fresh install, could fail and we should just carry on as it's not applicable } diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index ab88e6f18..9500b43ed 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -167,6 +167,16 @@ public class ImageService : IImageService return $"v{volumeId}_c{chapterId}"; } + /// + /// Returns the name format for a library cover image + /// + /// + /// + public static string GetLibraryFormat(int libraryId) + { + return $"l{libraryId}"; + } + /// /// Returns the name format for a series cover image /// diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index cfd62d898..b2e190e04 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -473,7 +473,7 @@ public class SeriesService : ISeriesService public async Task GetSeriesDetail(int seriesId, int userId) { var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - var libraryIds = (await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId)); + var libraryIds = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId); if (!libraryIds.Contains(series.LibraryId)) throw new UnauthorizedAccessException("User does not have access to the library this series belongs to"); diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index 4bb371ec9..c62e49ce2 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -162,6 +162,14 @@ public class BackupService : IBackupService var chapterImages = await _unitOfWork.ChapterRepository.GetCoverImagesForLockedChaptersAsync(); _directoryService.CopyFilesToDirectory( chapterImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); + + var libraryImages = await _unitOfWork.LibraryRepository.GetAllCoverImagesAsync(); + _directoryService.CopyFilesToDirectory( + libraryImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); + + var readingListImages = await _unitOfWork.ReadingListRepository.GetAllCoverImagesAsync(); + _directoryService.CopyFilesToDirectory( + readingListImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); } catch (IOException) { diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index fee51f562..183793457 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -77,6 +77,7 @@ public class LibraryWatcher : ILibraryWatcher _logger.LogInformation("[LibraryWatcher] Starting file watchers"); var libraryFolders = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync()) + .Where(l => l.FolderWatching) .SelectMany(l => l.Folders) .Distinct() .Select(Parser.Parser.NormalizePath) diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 4361a6433..6e0050724 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -1011,6 +1011,17 @@ public static class Parser return string.IsNullOrEmpty(author) ? string.Empty : author.Trim(); } + /// + /// Cleans user query string input + /// + /// + /// + public static string CleanQuery(string query) + { + return Uri.UnescapeDataString(query).Trim().Replace(@"%", string.Empty) + .Replace(":", string.Empty); + } + /// /// Normalizes the slashes in a path to be /// diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index a702396d3..f2020d86c 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -10,6 +10,7 @@ namespace API.SignalR; public static class MessageFactoryEntityTypes { + public const string Library = "library"; public const string Series = "series"; public const string Volume = "volume"; public const string Chapter = "chapter"; diff --git a/UI/Web/src/app/_models/library.ts b/UI/Web/src/app/_models/library.ts index 01a5727ae..f15fbab8c 100644 --- a/UI/Web/src/app/_models/library.ts +++ b/UI/Web/src/app/_models/library.ts @@ -10,4 +10,9 @@ export interface Library { lastScanned: string; type: LibraryType; folders: string[]; + coverImage?: string; + folderWatching: boolean; + includeInDashboard: boolean; + includeInRecommended: boolean; + includeInSearch: boolean; } \ No newline at end of file diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index cd72f2fce..cac8b743f 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -212,6 +212,13 @@ export class ActionFactoryService { }, ], }, + { + action: Action.Edit, + title: 'Settings', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, ]; this.collectionTagActions = [ diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index a6ab89927..3cc3a0355 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -61,6 +61,10 @@ export class ImageService implements OnDestroy { return part.substring(0, equalIndex).replace('Id', ''); } + getLibraryCoverImage(libraryId: number) { + return this.baseUrl + 'image/library-cover?libraryId=' + libraryId; + } + getVolumeCoverImage(volumeId: number) { return this.baseUrl + 'image/volume-cover?volumeId=' + volumeId; } diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 403fd409e..23478db4d 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -50,6 +50,10 @@ export class LibraryService { })); } + libraryNameExists(name: string) { + return this.httpClient.get(this.baseUrl + 'library/name-exists?name=' + name); + } + listDirectories(rootPath: string) { let query = ''; if (rootPath !== undefined && rootPath.length > 0) { diff --git a/UI/Web/src/app/_services/upload.service.ts b/UI/Web/src/app/_services/upload.service.ts index 8f3c1d07a..77fde32aa 100644 --- a/UI/Web/src/app/_services/upload.service.ts +++ b/UI/Web/src/app/_services/upload.service.ts @@ -38,6 +38,10 @@ export class UploadService { return this.httpClient.post(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url)}); } + updateLibraryCoverImage(libraryId: number, url: string) { + return this.httpClient.post(this.baseUrl + 'upload/library', {id: libraryId, url: this._cleanBase64Url(url)}); + } + resetChapterCoverLock(chapterId: number, ) { return this.httpClient.post(this.baseUrl + 'upload/reset-chapter-lock', {id: chapterId, url: ''}); } diff --git a/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.html b/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.html deleted file mode 100644 index 921ae6ca3..000000000 --- a/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.html +++ /dev/null @@ -1,42 +0,0 @@ - -
- - - -
\ No newline at end of file diff --git a/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.ts b/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.ts deleted file mode 100644 index 710d0e4d7..000000000 --- a/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { ToastrService } from 'ngx-toastr'; -import { ConfirmService } from 'src/app/shared/confirm.service'; -import { Library } from 'src/app/_models/library'; -import { LibraryService } from 'src/app/_services/library.service'; -import { SettingsService } from '../../settings.service'; -import { DirectoryPickerComponent, DirectoryPickerResult } from '../directory-picker/directory-picker.component'; - -@Component({ - selector: 'app-library-editor-modal', - templateUrl: './library-editor-modal.component.html', - styleUrls: ['./library-editor-modal.component.scss'] -}) -export class LibraryEditorModalComponent implements OnInit { - - @Input() library: Library | undefined = undefined; - - libraryForm: FormGroup = new FormGroup({ - name: new FormControl('', [Validators.required]), - type: new FormControl(0, [Validators.required]) - }); - - selectedFolders: string[] = []; - errorMessage = ''; - madeChanges = false; - libraryTypes: string[] = [] - - - constructor(private modalService: NgbModal, private libraryService: LibraryService, public modal: NgbActiveModal, private settingService: SettingsService, - private toastr: ToastrService, private confirmService: ConfirmService) { } - - ngOnInit(): void { - - this.settingService.getLibraryTypes().subscribe((types) => { - this.libraryTypes = types; - }); - this.setValues(); - - } - - - removeFolder(folder: string) { - this.selectedFolders = this.selectedFolders.filter(item => item !== folder); - this.madeChanges = true; - } - - async submitLibrary() { - const model = this.libraryForm.value; - model.folders = this.selectedFolders; - - if (this.libraryForm.errors) { - return; - } - - if (this.library !== undefined) { - model.id = this.library.id; - model.folders = model.folders.map((item: string) => item.startsWith('\\') ? item.substr(1, item.length) : item); - model.type = parseInt(model.type, 10); - - if (model.type !== this.library.type) { - if (!await this.confirmService.confirm(`Changing library type will trigger a new scan with different parsing rules and may lead to - series being re-created and hence you may loose progress and bookmarks. You should backup before you do this. Are you sure you want to continue?`)) return; - } - - this.libraryService.update(model).subscribe(() => { - this.close(true); - }, err => { - this.errorMessage = err; - }); - } else { - model.folders = model.folders.map((item: string) => item.startsWith('\\') ? item.substr(1, item.length) : item); - model.type = parseInt(model.type, 10); - this.libraryService.create(model).subscribe(() => { - this.toastr.success('Library created successfully.'); - this.toastr.info('A scan has been started.'); - this.close(true); - }, err => { - this.errorMessage = err; - }); - } - } - - close(returnVal= false) { - const model = this.libraryForm.value; - this.modal.close(returnVal); - } - - reset() { - this.setValues(); - } - - setValues() { - if (this.library !== undefined) { - this.libraryForm.get('name')?.setValue(this.library.name); - this.libraryForm.get('type')?.setValue(this.library.type); - this.selectedFolders = this.library.folders; - this.madeChanges = false; - } - } - - openDirectoryPicker() { - const modalRef = this.modalService.open(DirectoryPickerComponent, { scrollable: true, size: 'lg' }); - modalRef.closed.subscribe((closeResult: DirectoryPickerResult) => { - if (closeResult.success) { - if (!this.selectedFolders.includes(closeResult.folderPath)) { - this.selectedFolders.push(closeResult.folderPath); - this.madeChanges = true; - } - } - }); - } - -} diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts index 2bee7e133..efad95baa 100644 --- a/UI/Web/src/app/admin/admin.module.ts +++ b/UI/Web/src/app/admin/admin.module.ts @@ -5,7 +5,6 @@ import { DashboardComponent } from './dashboard/dashboard.component'; import { NgbDropdownModule, NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; import { ManageLibraryComponent } from './manage-library/manage-library.component'; import { ManageUsersComponent } from './manage-users/manage-users.component'; -import { LibraryEditorModalComponent } from './_modals/library-editor-modal/library-editor-modal.component'; import { SharedModule } from '../shared/shared.module'; import { LibraryAccessModalComponent } from './_modals/library-access-modal/library-access-modal.component'; import { DirectoryPickerComponent } from './_modals/directory-picker/directory-picker.component'; @@ -34,7 +33,6 @@ import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; ManageUsersComponent, DashboardComponent, ManageLibraryComponent, - LibraryEditorModalComponent, LibraryAccessModalComponent, DirectoryPickerComponent, ResetPasswordModalComponent, diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.html b/UI/Web/src/app/admin/manage-library/manage-library.component.html index e6418840d..873ef4141 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.html +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.html @@ -3,7 +3,7 @@

Libraries

-
    +
    • @@ -34,7 +34,4 @@ There are no libraries. Try creating one.

    - - - \ No newline at end of file diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.ts b/UI/Web/src/app/admin/manage-library/manage-library.component.ts index de3a24697..8e53f11e7 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.ts +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.ts @@ -4,12 +4,12 @@ import { ToastrService } from 'ngx-toastr'; import { Subject } from 'rxjs'; import { distinctUntilChanged, filter, take, takeUntil } from 'rxjs/operators'; import { ConfirmService } from 'src/app/shared/confirm.service'; +import { LibrarySettingsModalComponent } from 'src/app/sidenav/_components/library-settings-modal/library-settings-modal.component'; import { NotificationProgressEvent } from 'src/app/_models/events/notification-progress-event'; import { ScanSeriesEvent } from 'src/app/_models/events/scan-series-event'; -import { Library, LibraryType } from 'src/app/_models/library'; +import { Library } from 'src/app/_models/library'; import { LibraryService } from 'src/app/_services/library.service'; import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hub.service'; -import { LibraryEditorModalComponent } from '../_modals/library-editor-modal/library-editor-modal.component'; @Component({ selector: 'app-manage-library', @@ -20,7 +20,6 @@ import { LibraryEditorModalComponent } from '../_modals/library-editor-modal/lib export class ManageLibraryComponent implements OnInit, OnDestroy { libraries: Library[] = []; - createLibraryToggle = false; loading = false; /** * If a deletion is in progress for a library @@ -90,7 +89,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy { } editLibrary(library: Library) { - const modalRef = this.modalService.open(LibraryEditorModalComponent); + const modalRef = this.modalService.open(LibrarySettingsModalComponent, { size: 'xl' }); modalRef.componentInstance.library = library; modalRef.closed.pipe(takeUntil(this.onDestroy)).subscribe(refresh => { if (refresh) { @@ -100,7 +99,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy { } addLibrary() { - const modalRef = this.modalService.open(LibraryEditorModalComponent); + const modalRef = this.modalService.open(LibrarySettingsModalComponent, { size: 'xl' }); modalRef.closed.pipe(takeUntil(this.onDestroy)).subscribe(refresh => { if (refresh) { this.getLibraries(); diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.scss b/UI/Web/src/app/admin/manage-settings/manage-settings.component.scss index d58f774a2..042a4f5b4 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.scss +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.scss @@ -1,12 +1,3 @@ -.accent { - font-style: italic; - font-size: 0.7rem; - background-color: lightgray; - padding: 10px; - color: black; - border-radius: 6px; -} - .invalid-feedback { display: inherit; } \ No newline at end of file diff --git a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html index c544d0611..2b4e32750 100644 --- a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html +++ b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html @@ -1,7 +1,9 @@ -

    Nothing to show here. Add some metadata to your library, read something or rate something.

    +

    + Nothing to show here. Add some metadata to your library, read something or rate something. This library may also have recommendations turned off. +

    diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index c9e43440e..314424bf4 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -796,9 +796,9 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe toggleWantToRead() { if (this.isWantToRead) { - this.actionService.addMultipleSeriesToWantToReadList([this.series.id]); - } else { this.actionService.removeMultipleSeriesFromWantToReadList([this.series.id]); + } else { + this.actionService.addMultipleSeriesToWantToReadList([this.series.id]); } this.isWantToRead = !this.isWantToRead; diff --git a/UI/Web/src/app/sidenav/_components/library-settings-modal/library-settings-modal.component.html b/UI/Web/src/app/sidenav/_components/library-settings-modal/library-settings-modal.component.html new file mode 100644 index 000000000..41bf6d91e --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/library-settings-modal/library-settings-modal.component.html @@ -0,0 +1,162 @@ + +
    + +
    + diff --git a/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.scss b/UI/Web/src/app/sidenav/_components/library-settings-modal/library-settings-modal.component.scss similarity index 100% rename from UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.scss rename to UI/Web/src/app/sidenav/_components/library-settings-modal/library-settings-modal.component.scss diff --git a/UI/Web/src/app/sidenav/_components/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_components/library-settings-modal/library-settings-modal.component.ts new file mode 100644 index 000000000..c382af6b6 --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/library-settings-modal/library-settings-modal.component.ts @@ -0,0 +1,216 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; +import { debounceTime, distinctUntilChanged, filter, switchMap, tap } from 'rxjs'; +import { SettingsService } from 'src/app/admin/settings.service'; +import { DirectoryPickerComponent, DirectoryPickerResult } from 'src/app/admin/_modals/directory-picker/directory-picker.component'; +import { ConfirmService } from 'src/app/shared/confirm.service'; +import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; +import { Library, LibraryType } from 'src/app/_models/library'; +import { LibraryService } from 'src/app/_services/library.service'; +import { UploadService } from 'src/app/_services/upload.service'; + +enum TabID { + General = 'General', + Folder = 'Folder', + Cover = 'Cover', + Advanced = 'Advanced' +} + +enum StepID { + General = 0, + Folder = 1, + Cover = 2, + Advanced = 3 +} + +@Component({ + selector: 'app-library-settings-modal', + templateUrl: './library-settings-modal.component.html', + styleUrls: ['./library-settings-modal.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class LibrarySettingsModalComponent implements OnInit { + + @Input() library!: Library; + + active = TabID.General; + imageUrls: Array = []; + + libraryForm: FormGroup = new FormGroup({ + name: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + type: new FormControl(LibraryType.Manga, { nonNullable: true, validators: [Validators.required] }), + folderWatching: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), + includeInDashboard: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), + includeInRecommended: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), + includeInSearch: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), + }); + + selectedFolders: string[] = []; + madeChanges = false; + libraryTypes: string[] = [] + + isAddLibrary = false; + setupStep = StepID.General; + + get Breakpoint() { return Breakpoint; } + get TabID() { return TabID; } + get StepID() { return StepID; } + + constructor(public utilityService: UtilityService, private uploadService: UploadService, private modalService: NgbModal, + private settingService: SettingsService, public modal: NgbActiveModal, private confirmService: ConfirmService, + private libraryService: LibraryService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { } + + ngOnInit(): void { + + this.settingService.getLibraryTypes().subscribe((types) => { + this.libraryTypes = types; + this.cdRef.markForCheck(); + }); + + + if (this.library === undefined) { + this.isAddLibrary = true; + this.cdRef.markForCheck(); + } + + if (this.library?.coverImage != null && this.library?.coverImage !== '') { + this.imageUrls.push(this.library.coverImage); + this.cdRef.markForCheck(); + } + + this.libraryForm.get('name')?.valueChanges.pipe( + debounceTime(100), + distinctUntilChanged(), + switchMap(name => this.libraryService.libraryNameExists(name)), + tap(exists => { + const isExistingName = this.libraryForm.get('name')?.value === this.library?.name; + console.log('isExistingName', isExistingName) + if (!exists || isExistingName) { + this.libraryForm.get('name')?.setErrors(null); + } else { + this.libraryForm.get('name')?.setErrors({duplicateName: true}) + } + this.cdRef.markForCheck(); + }) + ).subscribe(); + + + this.setValues(); + } + + setValues() { + if (this.library !== undefined) { + this.libraryForm.get('name')?.setValue(this.library.name); + this.libraryForm.get('type')?.setValue(this.library.type); + this.libraryForm.get('folderWatching')?.setValue(this.library.folderWatching); + this.libraryForm.get('includeInDashboard')?.setValue(this.library.includeInDashboard); + this.libraryForm.get('includeInRecommended')?.setValue(this.library.includeInRecommended); + this.libraryForm.get('includeInSearch')?.setValue(this.library.includeInSearch); + this.selectedFolders = this.library.folders; + this.madeChanges = false; + this.cdRef.markForCheck(); + } + } + + isDisabled() { + return !(this.libraryForm.valid && this.selectedFolders.length > 0); + } + + reset() { + this.setValues(); + } + + close(returnVal= false) { + this.modal.close(returnVal); + } + + async save() { + const model = this.libraryForm.value; + model.folders = this.selectedFolders; + + if (this.libraryForm.errors) { + return; + } + + if (this.library !== undefined) { + model.id = this.library.id; + model.folders = model.folders.map((item: string) => item.startsWith('\\') ? item.substr(1, item.length) : item); + model.type = parseInt(model.type, 10); + + if (model.type !== this.library.type) { + if (!await this.confirmService.confirm(`Changing library type will trigger a new scan with different parsing rules and may lead to + series being re-created and hence you may loose progress and bookmarks. You should backup before you do this. Are you sure you want to continue?`)) return; + } + + this.libraryService.update(model).subscribe(() => { + this.close(true); + }); + } else { + model.folders = model.folders.map((item: string) => item.startsWith('\\') ? item.substr(1, item.length) : item); + model.type = parseInt(model.type, 10); + this.libraryService.create(model).subscribe(() => { + this.toastr.success('Library created successfully. A scan has been started.'); + this.close(true); + }); + } + } + + nextStep() { + this.setupStep++; + switch(this.setupStep) { + case StepID.Folder: + this.active = TabID.Folder; + break; + case StepID.Cover: + this.active = TabID.Cover; + break; + case StepID.Advanced: + this.active = TabID.Advanced; + break; + } + this.cdRef.markForCheck(); + } + + applyCoverImage(coverUrl: string) { + this.uploadService.updateLibraryCoverImage(this.library.id, coverUrl).subscribe(() => {}); + } + + resetCoverImage() { + this.uploadService.updateLibraryCoverImage(this.library.id, '').subscribe(() => {}); + } + + openDirectoryPicker() { + const modalRef = this.modalService.open(DirectoryPickerComponent, { scrollable: true, size: 'lg' }); + modalRef.closed.subscribe((closeResult: DirectoryPickerResult) => { + if (closeResult.success) { + if (!this.selectedFolders.includes(closeResult.folderPath)) { + this.selectedFolders.push(closeResult.folderPath); + this.madeChanges = true; + this.cdRef.markForCheck(); + } + } + }); + } + + removeFolder(folder: string) { + this.selectedFolders = this.selectedFolders.filter(item => item !== folder); + this.madeChanges = true; + this.cdRef.markForCheck(); + } + + isNextDisabled() { + switch (this.setupStep) { + case StepID.General: + return this.libraryForm.get('name')?.invalid || this.libraryForm.get('type')?.invalid; + case StepID.Folder: + return this.selectedFolders.length === 0; + case StepID.Cover: + return false; // Covers are optional + case StepID.Advanced: + return false; // Advanced are optional + } + } + +} diff --git a/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.html b/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.html index 2a1854130..55543c11e 100644 --- a/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.html +++ b/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.html @@ -15,7 +15,10 @@
    - + + icon + +
    diff --git a/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.scss b/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.scss index 95de8b9eb..132ad88bb 100644 --- a/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.scss +++ b/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.scss @@ -21,6 +21,11 @@ } } + .side-nav-img { + width: 20px; + height: 18px; + } + span { &:last-child { @@ -81,7 +86,6 @@ } .side-nav-text, i { - color: var(--side-nav-item-active-text-color) !important; } @@ -107,46 +111,19 @@ a { @media (max-width: 576px) { .side-nav-item { align-items: center; - //display: flex; - //justify-content: space-between; padding: 15px 10px; - //width: 100%; height: 70px; - //min-height: 40px; - // overflow: hidden; font-size: 1rem; - - //cursor: pointer; - + .side-nav-text { - // padding-left: 10px; - // opacity: 1; - // min-width: 100px; width: 100%; - - // div { - // min-width: 102px; - // width: 100% - // } } &.closed { - // .side-nav-text { - // opacity: 0; - // } - .card-actions { - //opacity: 0; font-size: inherit; } } - - // span { - // &:last-child { - // flex-grow: 1; - // justify-content: end; - // } - // } } } \ No newline at end of file diff --git a/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.ts b/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.ts index 58661d320..71523fee0 100644 --- a/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.ts +++ b/UI/Web/src/app/sidenav/side-nav-item/side-nav-item.component.ts @@ -14,6 +14,7 @@ export class SideNavItemComponent implements OnInit, OnDestroy { * Icon to display next to item. ie) 'fa-home' */ @Input() icon: string = ''; + @Input() imageUrl: string | null = ''; /** * Text for the item */ diff --git a/UI/Web/src/app/sidenav/side-nav/side-nav.component.html b/UI/Web/src/app/sidenav/side-nav/side-nav.component.html index ce8998ec4..2cfb35848 100644 --- a/UI/Web/src/app/sidenav/side-nav/side-nav.component.html +++ b/UI/Web/src/app/sidenav/side-nav/side-nav.component.html @@ -21,7 +21,7 @@ + [icon]="getLibraryTypeIcon(library.type)" [imageUrl]="getLibraryImage(library)" [title]="library.name" [comparisonMethod]="'startsWith'"> diff --git a/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts index 7364a8995..a8db4094f 100644 --- a/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts @@ -1,7 +1,9 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { Subject } from 'rxjs'; import { filter, map, shareReplay, take, takeUntil } from 'rxjs/operators'; +import { ImageService } from 'src/app/_services/image.service'; import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; import { Breakpoint, UtilityService } from '../../shared/_services/utility.service'; import { Library, LibraryType } from '../../_models/library'; @@ -10,6 +12,7 @@ import { Action, ActionFactoryService, ActionItem } from '../../_services/action import { ActionService } from '../../_services/action.service'; import { LibraryService } from '../../_services/library.service'; import { NavService } from '../../_services/nav.service'; +import { LibrarySettingsModalComponent } from '../_components/library-settings-modal/library-settings-modal.component'; @Component({ selector: 'app-side-nav', @@ -33,7 +36,8 @@ export class SideNavComponent implements OnInit, OnDestroy { constructor(public accountService: AccountService, private libraryService: LibraryService, public utilityService: UtilityService, private messageHub: MessageHubService, private actionFactoryService: ActionFactoryService, private actionService: ActionService, - public navService: NavService, private router: Router, private readonly cdRef: ChangeDetectorRef) { + public navService: NavService, private router: Router, private readonly cdRef: ChangeDetectorRef, + private modalService: NgbModal, private imageService: ImageService) { this.router.events.pipe( filter(event => event instanceof NavigationEnd), @@ -64,7 +68,7 @@ export class SideNavComponent implements OnInit, OnDestroy { this.messageHub.messages$.pipe(takeUntil(this.onDestroy), filter(event => event.event === EVENTS.LibraryModified)).subscribe(event => { this.libraryService.getLibraries().pipe(take(1), shareReplay()).subscribe((libraries: Library[]) => { - this.libraries = libraries; + this.libraries = [...libraries]; this.cdRef.markForCheck(); }); }); @@ -86,6 +90,20 @@ export class SideNavComponent implements OnInit, OnDestroy { case (Action.AnalyzeFiles): this.actionService.analyzeFiles(library); break; + case (Action.Edit): + const modalRef = this.modalService.open(LibrarySettingsModalComponent, { size: 'xl' }); + modalRef.componentInstance.library = library; + modalRef.closed.subscribe((closeResult: {success: boolean, library: Library, coverImageUpdate: boolean}) => { + window.scrollTo(0, 0); + if (closeResult.success) { + + } + + if (closeResult.coverImageUpdate) { + + } + }); + break; default: break; } @@ -108,6 +126,11 @@ export class SideNavComponent implements OnInit, OnDestroy { } } + getLibraryImage(library: Library) { + if (library.coverImage) return this.imageService.getLibraryCoverImage(library.id); + return null; + } + toggleNavBar() { this.navService.toggleSideNav(); } diff --git a/UI/Web/src/app/sidenav/sidenav.module.ts b/UI/Web/src/app/sidenav/sidenav.module.ts index 119c2604b..08bc2a040 100644 --- a/UI/Web/src/app/sidenav/sidenav.module.ts +++ b/UI/Web/src/app/sidenav/sidenav.module.ts @@ -5,9 +5,10 @@ import { SideNavItemComponent } from './side-nav-item/side-nav-item.component'; import { SideNavComponent } from './side-nav/side-nav.component'; import { PipeModule } from '../pipe/pipe.module'; import { CardsModule } from '../cards/cards.module'; -import { FormsModule } from '@angular/forms'; -import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { RouterModule } from '@angular/router'; +import { LibrarySettingsModalComponent } from './_components/library-settings-modal/library-settings-modal.component'; @@ -15,7 +16,8 @@ import { RouterModule } from '@angular/router'; declarations: [ SideNavCompanionBarComponent, SideNavItemComponent, - SideNavComponent + SideNavComponent, + LibrarySettingsModalComponent ], imports: [ CommonModule, @@ -24,6 +26,8 @@ import { RouterModule } from '@angular/router'; CardsModule, FormsModule, NgbTooltipModule, + NgbNavModule, + ReactiveFormsModule ], exports: [ SideNavCompanionBarComponent, diff --git a/UI/Web/src/theme/utilities/_global.scss b/UI/Web/src/theme/utilities/_global.scss index 3ff1fd933..5ba860e1b 100644 --- a/UI/Web/src/theme/utilities/_global.scss +++ b/UI/Web/src/theme/utilities/_global.scss @@ -25,6 +25,9 @@ hr { color: var(--accent-text-color) !important; box-shadow: inset 0px 0px 8px 1px var(--accent-bg-color) !important; font-size: var(--accent-text-size) !important; + font-style: italic; + padding: 10px; + border-radius: 6px; } .text-muted { @@ -37,4 +40,5 @@ hr { .form-switch .form-check-input:checked { background-color: var(--primary-color); -} \ No newline at end of file +} +