mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Library Settings Modal + New Library Settings (#1660)
* Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> * Fixed want to read button on series detail not performing the correct action * Started the library settings. Added ability to update a cover image for a library. Updated backup db to also copy reading list (and now library) cover images. * Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library. * Fixed a missing update event in backend when updating a library. * Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid. * Trim library names before you check anything * General code cleanup * Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries. Refactored some code to streamline perf in some flows. * Removed old components replaced with new modal * Code smells Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
parent
48b15e564d
commit
73d77e6264
@ -247,7 +247,6 @@ public class AccountController : BaseApiController
|
||||
[HttpGet("roles")]
|
||||
public ActionResult<IList<string>> GetRoles()
|
||||
{
|
||||
// TODO: This should be moved to ServerController
|
||||
return typeof(PolicyConstants)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.FieldType == typeof(string))
|
||||
|
@ -41,6 +41,22 @@ public class ImageController : BaseApiController
|
||||
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cover image for Library
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("library-cover")]
|
||||
[ResponseCache(CacheProfileName = "Images")]
|
||||
public async Task<ActionResult> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cover image for Volume
|
||||
/// </summary>
|
||||
|
@ -295,35 +295,62 @@ public class LibraryController : BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the library name exists or not
|
||||
/// </summary>
|
||||
/// <param name="name"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("name-exists")]
|
||||
public async Task<ActionResult<bool>> IsLibraryNameValid(string name)
|
||||
{
|
||||
return Ok(await _unitOfWork.LibraryRepository.LibraryExists(name.Trim()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing Library with new name, folders, and/or type.
|
||||
/// </summary>
|
||||
/// <remarks>Any folder or type change will invoke a scan.</remarks>
|
||||
/// <param name="libraryForUserDto"></param>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("update")]
|
||||
public async Task<ActionResult> UpdateLibrary(UpdateLibraryDto libraryForUserDto)
|
||||
public async Task<ActionResult> 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();
|
||||
|
||||
}
|
||||
|
@ -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(".", "");
|
||||
|
@ -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<ActionResult<SearchResultGroupDto>> 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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
}
|
||||
|
@ -266,6 +266,63 @@ public class UploadController : BaseApiController
|
||||
return BadRequest("Unable to save cover image to Chapter");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces library cover image with a base64 encoded image. If empty string passed, will reset to null.
|
||||
/// </summary>
|
||||
/// <param name="uploadFileDto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[RequestSizeLimit(8_000_000)]
|
||||
[HttpPost("library")]
|
||||
public async Task<ActionResult> 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
|
||||
/// </summary>
|
||||
|
@ -13,5 +13,25 @@ public class LibraryDto
|
||||
/// </summary>
|
||||
public DateTime LastScanned { get; init; }
|
||||
public LibraryType Type { get; init; }
|
||||
/// <summary>
|
||||
/// An optional Cover Image or null
|
||||
/// </summary>
|
||||
public string CoverImage { get; init; }
|
||||
/// <summary>
|
||||
/// If Folder Watching is enabled for this library
|
||||
/// </summary>
|
||||
public bool FolderWatching { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Include Library series on Dashboard Streams
|
||||
/// </summary>
|
||||
public bool IncludeInDashboard { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Include Library series on Recommended Streams
|
||||
/// </summary>
|
||||
public bool IncludeInRecommended { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Include library series in Search
|
||||
/// </summary>
|
||||
public bool IncludeInSearch { get; set; } = true;
|
||||
public ICollection<string> Folders { get; init; }
|
||||
}
|
||||
|
@ -9,4 +9,9 @@ public class UpdateLibraryDto
|
||||
public string Name { get; init; }
|
||||
public LibraryType Type { get; set; }
|
||||
public IEnumerable<string> Folders { get; init; }
|
||||
public bool FolderWatching { get; init; }
|
||||
public bool IncludeInDashboard { get; init; }
|
||||
public bool IncludeInRecommended { get; init; }
|
||||
public bool IncludeInSearch { get; init; }
|
||||
|
||||
}
|
||||
|
@ -89,6 +89,20 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.GlobalPageLayoutMode)
|
||||
.HasDefaultValue(PageLayoutMode.Cards);
|
||||
|
||||
|
||||
builder.Entity<Library>()
|
||||
.Property(b => b.FolderWatching)
|
||||
.HasDefaultValue(true);
|
||||
builder.Entity<Library>()
|
||||
.Property(b => b.IncludeInDashboard)
|
||||
.HasDefaultValue(true);
|
||||
builder.Entity<Library>()
|
||||
.Property(b => b.IncludeInRecommended)
|
||||
.HasDefaultValue(true);
|
||||
builder.Entity<Library>()
|
||||
.Property(b => b.IncludeInSearch)
|
||||
.HasDefaultValue(true);
|
||||
}
|
||||
|
||||
|
||||
|
1693
API/Data/Migrations/20221118131123_ExtendedLibrarySettings.Designer.cs
generated
Normal file
1693
API/Data/Migrations/20221118131123_ExtendedLibrarySettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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<bool>(
|
||||
name: "FolderWatching",
|
||||
table: "Library",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IncludeInDashboard",
|
||||
table: "Library",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IncludeInRecommended",
|
||||
table: "Library",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
@ -545,6 +545,26 @@ namespace API.Data.Migrations
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("FolderWatching")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("IncludeInDashboard")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("IncludeInRecommended")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("IncludeInSearch")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -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<IEnumerable<Library>> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None);
|
||||
Task<bool> DeleteLibrary(int libraryId);
|
||||
Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId);
|
||||
Task<IEnumerable<int>> GetLibraryIdsForUserIdAsync(int userId);
|
||||
IEnumerable<int> GetLibraryIdsForUserIdAsync(int userId, QueryContext queryContext = QueryContext.None);
|
||||
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
|
||||
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IEnumerable<int> libraryIds, LibraryIncludes includes = LibraryIncludes.None);
|
||||
Task<int> GetTotalFiles();
|
||||
@ -48,7 +49,8 @@ public interface ILibraryRepository
|
||||
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync();
|
||||
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
|
||||
Task<bool> DoAnySeriesFoldersMatch(IEnumerable<string> folders);
|
||||
Library GetLibraryByFolder(string folder);
|
||||
Task<string> GetLibraryCoverImageAsync(int libraryId);
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
}
|
||||
|
||||
public class LibraryRepository : ILibraryRepository
|
||||
@ -126,12 +128,13 @@ public class LibraryRepository : ILibraryRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<int>> GetLibraryIdsForUserIdAsync(int userId)
|
||||
public IEnumerable<int> 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<LibraryType> 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<string> 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<IList<string>> GetAllCoverImagesAsync()
|
||||
{
|
||||
return await _context.ReadingList
|
||||
.Select(t => t.CoverImage)
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
@ -37,6 +37,18 @@ public enum SeriesIncludes
|
||||
Library = 16,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
/// <param name="libraryIds"></param>
|
||||
/// <param name="searchQuery"></param>
|
||||
/// <returns></returns>
|
||||
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery);
|
||||
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery);
|
||||
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None);
|
||||
Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId);
|
||||
Task<Series> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
|
||||
@ -257,7 +269,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
/// <returns></returns>
|
||||
public async Task<PagedList<SeriesDto>> 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<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
@ -267,13 +279,14 @@ public class SeriesRepository : ISeriesRepository
|
||||
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
private async Task<List<int>> GetUserLibraries(int libraryId, int userId)
|
||||
private async Task<List<int>> 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<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery)
|
||||
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> 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
|
||||
/// <returns></returns>
|
||||
public async Task<PagedList<SeriesDto>> 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<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter)
|
||||
private async Task<IQueryable<Series>> 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<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, IQueryable<Series> 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<PagedList<SeriesDto>> 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
|
||||
/// <returns></returns>
|
||||
public async Task<PagedList<SeriesDto>> 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<SeriesDto> 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<PagedList<SeriesDto>> 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<PagedList<SeriesDto>> 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<PagedList<SeriesDto>> 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
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="libraryId">0 for no library filter</param>
|
||||
/// <param name="queryContext">Defaults to None - The context behind this query, so appropriate restrictions can be placed</param>
|
||||
/// <returns></returns>
|
||||
private IQueryable<int> GetLibraryIdsForUser(int userId, int libraryId = 0)
|
||||
private IQueryable<int> 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<RelatedSeriesDto> 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);
|
||||
|
@ -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; }
|
||||
/// <summary>
|
||||
/// This is not used, but planned once we build out a Library detail page
|
||||
/// </summary>
|
||||
[Obsolete("This has never been coded for. Likely we can remove it.")]
|
||||
public string CoverImage { get; set; }
|
||||
public LibraryType Type { get; set; }
|
||||
/// <summary>
|
||||
/// If Folder Watching is enabled for this library
|
||||
/// </summary>
|
||||
public bool FolderWatching { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Include Library series on Dashboard Streams
|
||||
/// </summary>
|
||||
public bool IncludeInDashboard { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Include Library series on Recommended Streams
|
||||
/// </summary>
|
||||
public bool IncludeInRecommended { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Include library series in Search
|
||||
/// </summary>
|
||||
public bool IncludeInSearch { get; set; } = true;
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
/// <summary>
|
||||
@ -27,4 +37,5 @@ public class Library : IEntityDate
|
||||
public ICollection<FolderPath> Folders { get; set; }
|
||||
public ICollection<AppUser> AppUsers { get; set; }
|
||||
public ICollection<Series> Series { get; set; }
|
||||
|
||||
}
|
||||
|
@ -158,4 +158,32 @@ public static class QueryableExtensions
|
||||
|
||||
return query.AsSplitQuery();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies restriction based on if the Library has restrictions (like include in search)
|
||||
/// </summary>
|
||||
/// <param name="query"></param>
|
||||
/// <param name="context"></param>
|
||||
/// <returns></returns>
|
||||
public static IQueryable<Library> IsRestricted(this IQueryable<Library> 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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -167,6 +167,16 @@ public class ImageService : IImageService
|
||||
return $"v{volumeId}_c{chapterId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the name format for a library cover image
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <returns></returns>
|
||||
public static string GetLibraryFormat(int libraryId)
|
||||
{
|
||||
return $"l{libraryId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the name format for a series cover image
|
||||
/// </summary>
|
||||
|
@ -473,7 +473,7 @@ public class SeriesService : ISeriesService
|
||||
public async Task<SeriesDetailDto> 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");
|
||||
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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)
|
||||
|
@ -1011,6 +1011,17 @@ public static class Parser
|
||||
return string.IsNullOrEmpty(author) ? string.Empty : author.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans user query string input
|
||||
/// </summary>
|
||||
/// <param name="query"></param>
|
||||
/// <returns></returns>
|
||||
public static string CleanQuery(string query)
|
||||
{
|
||||
return Uri.UnescapeDataString(query).Trim().Replace(@"%", string.Empty)
|
||||
.Replace(":", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the slashes in a path to be <see cref="Path.AltDirectorySeparatorChar"/>
|
||||
/// </summary>
|
||||
|
@ -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";
|
||||
|
@ -10,4 +10,9 @@ export interface Library {
|
||||
lastScanned: string;
|
||||
type: LibraryType;
|
||||
folders: string[];
|
||||
coverImage?: string;
|
||||
folderWatching: boolean;
|
||||
includeInDashboard: boolean;
|
||||
includeInRecommended: boolean;
|
||||
includeInSearch: boolean;
|
||||
}
|
@ -212,6 +212,13 @@ export class ActionFactoryService {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
action: Action.Edit,
|
||||
title: 'Settings',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true,
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
this.collectionTagActions = [
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -50,6 +50,10 @@ export class LibraryService {
|
||||
}));
|
||||
}
|
||||
|
||||
libraryNameExists(name: string) {
|
||||
return this.httpClient.get<boolean>(this.baseUrl + 'library/name-exists?name=' + name);
|
||||
}
|
||||
|
||||
listDirectories(rootPath: string) {
|
||||
let query = '';
|
||||
if (rootPath !== undefined && rootPath.length > 0) {
|
||||
|
@ -38,6 +38,10 @@ export class UploadService {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url)});
|
||||
}
|
||||
|
||||
updateLibraryCoverImage(libraryId: number, url: string) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/library', {id: libraryId, url: this._cleanBase64Url(url)});
|
||||
}
|
||||
|
||||
resetChapterCoverLock(chapterId: number, ) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/reset-chapter-lock', {id: chapterId, url: ''});
|
||||
}
|
||||
|
@ -1,42 +0,0 @@
|
||||
|
||||
<form [formGroup]="libraryForm">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{this.library !== undefined ? 'Edit' : 'New'}} Library</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info" *ngIf="errorMessage !== ''">
|
||||
<strong>Error: </strong> {{errorMessage}}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="library-name" class="form-label">Name</label>
|
||||
<input id="library-name" class="form-control" formControlName="name" type="text">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="library-type" class="form-label">Type</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="typeTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #typeTooltip>Library type determines how filenames are parsed and if the UI shows Chapters (Manga) vs Issues (Comics). Book work the same way as Manga but fall back to embedded data.</ng-template>
|
||||
<span class="visually-hidden" id="library-type-help">Library type determines how filenames are parsed and if the UI shows Chapters (Manga) vs Issues (Comics). Book work the same way as Manga but fall back to embedded data.</span>
|
||||
<select class="form-select" id="library-type" formControlName="type" aria-describedby="library-type-help"> <!-- [attr.disabled]="this.library" -->
|
||||
<option [value]="i" *ngFor="let opt of libraryTypes; let i = index">{{opt}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<h4>Folders <button type="button" class="btn float-end btn-sm" (click)="openDirectoryPicker()"><i class="fa fa-plus" aria-hidden="true"></i></button></h4>
|
||||
|
||||
<ul class="list-group" style="width: 100%">
|
||||
<li class="list-group-item" *ngFor="let folder of selectedFolders; let i = index">
|
||||
{{folder}}
|
||||
<button class="btn float-end btn-sm" (click)="removeFolder(folder)"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" (click)="reset()">Reset</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" (click)="submitLibrary()" [disabled]="!libraryForm.dirty && !madeChanges">Save</button>
|
||||
</div>
|
||||
</form>
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -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,
|
||||
|
@ -3,7 +3,7 @@
|
||||
<div class="col-8"><h3>Libraries</h3></div>
|
||||
<div class="col-4"><button class="btn btn-primary float-end" (click)="addLibrary()" title="Add Library"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden"> Add Library</span></button></div>
|
||||
</div>
|
||||
<ul class="list-group" *ngIf="!createLibraryToggle; else createLibrary">
|
||||
<ul class="list-group">
|
||||
<li *ngFor="let library of libraries; let idx = index; trackBy: libraryTrackBy" class="list-group-item no-hover">
|
||||
<div>
|
||||
<h4>
|
||||
@ -34,7 +34,4 @@
|
||||
There are no libraries. Try creating one.
|
||||
</li>
|
||||
</ul>
|
||||
<ng-template #createLibrary>
|
||||
<app-library-editor-modal></app-library-editor-modal>
|
||||
</ng-template>
|
||||
</div>
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
|
||||
|
||||
<ng-container *ngIf="all$ | async as all">
|
||||
<p *ngIf="all.length === 0">Nothing to show here. Add some metadata to your library, read something or rate something.</p>
|
||||
<p *ngIf="all.length === 0">
|
||||
Nothing to show here. Add some metadata to your library, read something or rate something. This library may also have recommendations turned off.
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="onDeck$ | async as onDeck">
|
||||
|
@ -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;
|
||||
|
@ -0,0 +1,162 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">
|
||||
<ng-container *ngIf="!isAddLibrary; else addLibraryTitle">
|
||||
Edit {{library.name | sentenceCase}}
|
||||
</ng-container>
|
||||
<ng-template #addLibraryTitle>
|
||||
Add Library
|
||||
</ng-template>
|
||||
</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<form [formGroup]="libraryForm">
|
||||
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills"
|
||||
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
|
||||
<li [ngbNavItem]="TabID.General">
|
||||
<a ngbNavLink>{{TabID.General}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="mb-3">
|
||||
<label for="library-name" class="form-label">Name</label>
|
||||
<input id="library-name" class="form-control" formControlName="name" type="text" [class.is-invalid]="libraryForm.get('name')?.invalid && libraryForm.get('name')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="libraryForm.dirty || libraryForm.touched">
|
||||
<div *ngIf="libraryForm.get('name')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
<div *ngIf="libraryForm.get('name')?.errors?.duplicateName">
|
||||
Library name must be unique
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="library-type" class="form-label">Type</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="typeTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #typeTooltip>Library type determines how filenames are parsed and if the UI shows Chapters (Manga) vs Issues (Comics). Book work the same way as Manga but have different naming in the UI.</ng-template>
|
||||
<span class="visually-hidden" id="library-type-help">Library type determines how filenames are parsed and if the UI shows Chapters (Manga) vs Issues (Comics). Book work the same way as Manga but have different naming in the UI.</span>
|
||||
<select class="form-select" id="library-type" formControlName="type" aria-describedby="library-type-help">
|
||||
<option [value]="i" *ngFor="let opt of libraryTypes; let i = index">{{opt}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div *ngIf="!isAddLibrary">
|
||||
Last Scanned:
|
||||
<span *ngIf="library.lastScanned == '0001-01-01T00:00:00'; else activeDate">Never</span>
|
||||
<ng-template #activeDate>
|
||||
{{library.lastScanned | date: 'short'}}
|
||||
</ng-template>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
|
||||
<li [ngbNavItem]="TabID.Folder" [disabled]="isAddLibrary && setupStep < 1">
|
||||
<a ngbNavLink>{{TabID.Folder}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p>Add folders to your library</p>
|
||||
<ul class="list-group" style="width: 100%">
|
||||
<li class="list-group-item" *ngFor="let folder of selectedFolders; let i = index">
|
||||
{{folder}}
|
||||
<button class="btn float-end btn-sm" (click)="removeFolder(folder)"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="row mt-2">
|
||||
<button class="btn btn-secondary float-end btn-sm" (click)="openDirectoryPicker()">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i>
|
||||
Browse for Media Folders
|
||||
</button>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<p>Help us out by following <a href="https://wiki.kavitareader.com/en/guides/managing-your-files" rel="noopener noreferrer" target="_blank" referrerpolicy="no-refer">our guide</a> to naming and organizing your media.</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="TabID.Cover" [disabled]="isAddLibrary && setupStep < 2">
|
||||
<a ngbNavLink>{{TabID.Cover}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p *ngIf="isAddLibrary" class="alert alert-secondary" role="alert">Custom library image icons are optional</p>
|
||||
<p>Library image should not be large. Aim for a small file, 32x32 pixels in size. Kavita does not perform validation on size.</p>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls"
|
||||
[showReset]="false"
|
||||
[showApplyButton]="true"
|
||||
(applyCover)="applyCoverImage($event)"
|
||||
(resetCover)="resetCoverImage()"
|
||||
>
|
||||
</app-cover-image-chooser>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="TabID.Advanced" [disabled]="isAddLibrary && setupStep < 3">
|
||||
<a ngbNavLink>{{TabID.Advanced}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12 col-sm-12 pe-2 mb-2">
|
||||
<div class="mb-3 mt-1">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="lib-folder-watching" role="switch" formControlName="folderWatching" class="form-check-input" aria-labelledby="auto-close-label">
|
||||
<label class="form-check-label" for="lib-folder-watching">Folder Watching</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="accent">
|
||||
Override Server folder watching for this library. If off, folder watching won't run on the folders this library contains. If libraries share folders, then folders may still be ran against.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12 col-sm-12 pe-2 mb-2">
|
||||
<div class="mb-3 mt-1">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="include-dashboard" role="switch" formControlName="includeInDashboard" class="form-check-input" aria-labelledby="auto-close-label">
|
||||
<label class="form-check-label" for="include-dashboard">Include in Dashboard</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="accent">
|
||||
Should series from the library be included on the Dashboard. This affects all streams, like On Deck, Recently Updated, Recently Added, or any custom additions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12 col-sm-12 pe-2 mb-2">
|
||||
<div class="mb-3 mt-1">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="include-recommended" role="switch" formControlName="includeInRecommended" class="form-check-input" aria-labelledby="auto-close-label">
|
||||
<label class="form-check-label" for="include-recommended">Include in Recommended</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="accent">
|
||||
Should series from the library be included on the Recommended page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12 col-sm-12 pe-2 mb-2">
|
||||
<div class="mb-3 mt-1">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="include-search" role="switch" formControlName="includeInSearch" class="form-check-input" aria-labelledby="auto-close-label">
|
||||
<label class="form-check-label" for="include-search">Include in Search</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="accent">
|
||||
Should series and any derived information (genres, people, files) from the library be included in search results.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" (click)="reset()">Reset</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
|
||||
|
||||
<ng-container *ngIf="isAddLibrary && setupStep != 3; else editLibraryButton">
|
||||
<button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="isNextDisabled() || libraryForm.invalid">Next</button>
|
||||
</ng-container>
|
||||
<ng-template #editLibraryButton>
|
||||
<button type="button" class="btn btn-primary" [disabled]="isDisabled()" (click)="save()">Save</button>
|
||||
</ng-template>
|
||||
</div>
|
@ -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<string> = [];
|
||||
|
||||
libraryForm: FormGroup = new FormGroup({
|
||||
name: new FormControl<string>('', { nonNullable: true, validators: [Validators.required] }),
|
||||
type: new FormControl<LibraryType>(LibraryType.Manga, { nonNullable: true, validators: [Validators.required] }),
|
||||
folderWatching: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
|
||||
includeInDashboard: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
|
||||
includeInRecommended: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
|
||||
includeInSearch: new FormControl<boolean>(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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -15,7 +15,10 @@
|
||||
<div class="active-highlight"></div>
|
||||
<span class="phone-hidden" title="{{title}}">
|
||||
<div>
|
||||
<i class="fa {{icon}}" aria-hidden="true"></i>
|
||||
<ng-container *ngIf="imageUrl != null && imageUrl != ''; else iconImg">
|
||||
<img [src]="imageUrl" alt="icon" class="side-nav-img">
|
||||
</ng-container>
|
||||
<ng-template #iconImg><i class="fa {{icon}}" aria-hidden="true"></i></ng-template>
|
||||
</div>
|
||||
</span>
|
||||
<span class="side-nav-text">
|
||||
|
@ -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;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
@ -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
|
||||
*/
|
||||
|
@ -21,7 +21,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<app-side-nav-item *ngFor="let library of libraries | filter: filterLibrary" [link]="'/library/' + library.id + '/'"
|
||||
[icon]="getLibraryTypeIcon(library.type)" [title]="library.name" [comparisonMethod]="'startsWith'">
|
||||
[icon]="getLibraryTypeIcon(library.type)" [imageUrl]="getLibraryImage(library)" [title]="library.name" [comparisonMethod]="'startsWith'">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="actions" [labelBy]="library.name" iconClass="fa-ellipsis-v" (actionHandler)="performAction($event, library)"></app-card-actionables>
|
||||
</ng-container>
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user