mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
More Filtering and Support for ComicInfo v2.1 (draft) Tags (#851)
* Added a reoccuring task to cleanup db entries that might be abandoned. On library page, the Library in question will be prepoulated. * Laid out the foundation for customized sorting. Added all series page to the UI when clicking on Libraries section header on home page so user can apply any filtering they like. * When filtering, the current library filter will now automatically filter out the options for people and genres. * Implemented Sorting controls * Clear now clears sorting and read progress. Sorting is disabled on deck and recently added. * Fixed an issue where all-series page couldn't click to open series * Don't let the user unselect the last read progress. Added new comicinfo v2.1 draft tags. * Hooked in Translator tag into backend and UI. * Fixed an issue where you could open multiple typeaheads at the same time * Integrated Translator and Tags ComicInfo extension fields. Started work on a badge expander. * Reworked a bit more on badge expander. Added the UI code for Age Rating and Tags * Integrated backend for Tags, Translator, and Age Rating * Metadata tags now collapse if more than 4 present * Some code cleanup * Made the not read badge slightly smaller
This commit is contained in:
parent
21da5d8134
commit
94bad97511
119
API.Tests/Helpers/TagHelperTests.cs
Normal file
119
API.Tests/Helpers/TagHelperTests.cs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Data;
|
||||||
|
using API.Entities;
|
||||||
|
using API.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace API.Tests.Helpers;
|
||||||
|
|
||||||
|
public class TagHelperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void UpdateTag_ShouldAddNewTag()
|
||||||
|
{
|
||||||
|
var allTags = new List<Tag>
|
||||||
|
{
|
||||||
|
DbFactory.Tag("Action", false),
|
||||||
|
DbFactory.Tag("action", false),
|
||||||
|
DbFactory.Tag("Sci-fi", false),
|
||||||
|
};
|
||||||
|
var tagAdded = new List<Tag>();
|
||||||
|
|
||||||
|
TagHelper.UpdateTag(allTags, new[] {"Action", "Adventure"}, false, (tag, added) =>
|
||||||
|
{
|
||||||
|
if (added)
|
||||||
|
{
|
||||||
|
tagAdded.Add(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(1, tagAdded.Count);
|
||||||
|
Assert.Equal(4, allTags.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateTag_ShouldNotAddDuplicateTag()
|
||||||
|
{
|
||||||
|
var allTags = new List<Tag>
|
||||||
|
{
|
||||||
|
DbFactory.Tag("Action", false),
|
||||||
|
DbFactory.Tag("action", false),
|
||||||
|
DbFactory.Tag("Sci-fi", false),
|
||||||
|
|
||||||
|
};
|
||||||
|
var tagAdded = new List<Tag>();
|
||||||
|
|
||||||
|
TagHelper.UpdateTag(allTags, new[] {"Action", "Scifi"}, false, (tag, added) =>
|
||||||
|
{
|
||||||
|
if (added)
|
||||||
|
{
|
||||||
|
tagAdded.Add(tag);
|
||||||
|
}
|
||||||
|
TagHelper.AddTagIfNotExists(allTags, tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(3, allTags.Count);
|
||||||
|
Assert.Empty(tagAdded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddTag_ShouldAddOnlyNonExistingTag()
|
||||||
|
{
|
||||||
|
var existingTags = new List<Tag>
|
||||||
|
{
|
||||||
|
DbFactory.Tag("Action", false),
|
||||||
|
DbFactory.Tag("action", false),
|
||||||
|
DbFactory.Tag("Sci-fi", false),
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action", false));
|
||||||
|
Assert.Equal(3, existingTags.Count);
|
||||||
|
|
||||||
|
TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("action", false));
|
||||||
|
Assert.Equal(3, existingTags.Count);
|
||||||
|
|
||||||
|
TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Shonen", false));
|
||||||
|
Assert.Equal(4, existingTags.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddTag_ShouldNotAddSameNameAndExternal()
|
||||||
|
{
|
||||||
|
var existingTags = new List<Tag>
|
||||||
|
{
|
||||||
|
DbFactory.Tag("Action", false),
|
||||||
|
DbFactory.Tag("action", false),
|
||||||
|
DbFactory.Tag("Sci-fi", false),
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action", true));
|
||||||
|
Assert.Equal(3, existingTags.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KeepOnlySamePeopleBetweenLists()
|
||||||
|
{
|
||||||
|
var existingTags = new List<Tag>
|
||||||
|
{
|
||||||
|
DbFactory.Tag("Action", false),
|
||||||
|
DbFactory.Tag("Sci-fi", false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var peopleFromChapters = new List<Tag>
|
||||||
|
{
|
||||||
|
DbFactory.Tag("Action", false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var tagRemoved = new List<Tag>();
|
||||||
|
TagHelper.KeepOnlySameTagBetweenLists(existingTags,
|
||||||
|
peopleFromChapters, tag =>
|
||||||
|
{
|
||||||
|
tagRemoved.Add(tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(1, tagRemoved.Count);
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,14 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Filtering;
|
||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
using Kavita.Common.Extensions;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace API.Controllers;
|
namespace API.Controllers;
|
||||||
@ -17,15 +23,97 @@ public class MetadataController : BaseApiController
|
|||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches genres from the instance
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryIds">String separated libraryIds or null for all genres</param>
|
||||||
|
/// <returns></returns>
|
||||||
[HttpGet("genres")]
|
[HttpGet("genres")]
|
||||||
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres()
|
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds)
|
||||||
{
|
{
|
||||||
|
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||||
|
if (ids != null && ids.Count > 0)
|
||||||
|
{
|
||||||
|
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids));
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync());
|
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches people from the instance
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryIds">String separated libraryIds or null for all people</param>
|
||||||
|
/// <returns></returns>
|
||||||
[HttpGet("people")]
|
[HttpGet("people")]
|
||||||
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople()
|
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(string? libraryIds)
|
||||||
{
|
{
|
||||||
|
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||||
|
if (ids != null && ids.Count > 0)
|
||||||
|
{
|
||||||
|
return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids));
|
||||||
|
}
|
||||||
return Ok(await _unitOfWork.PersonRepository.GetAllPeople());
|
return Ok(await _unitOfWork.PersonRepository.GetAllPeople());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches all tags from the instance
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryIds">String separated libraryIds or null for all tags</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("tags")]
|
||||||
|
public async Task<ActionResult<IList<PersonDto>>> GetAllTags(string? libraryIds)
|
||||||
|
{
|
||||||
|
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||||
|
if (ids != null && ids.Count > 0)
|
||||||
|
{
|
||||||
|
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids));
|
||||||
|
}
|
||||||
|
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches all age ratings from the instance
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("age-ratings")]
|
||||||
|
public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(string? libraryIds)
|
||||||
|
{
|
||||||
|
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||||
|
if (ids != null && ids.Count > 0)
|
||||||
|
{
|
||||||
|
return Ok(await _unitOfWork.SeriesRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(Enum.GetValues<AgeRating>().Select(t => new AgeRatingDto()
|
||||||
|
{
|
||||||
|
Title = t.ToDescription(),
|
||||||
|
Value = t
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches all age ratings from the instance
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("languages")]
|
||||||
|
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
|
||||||
|
{
|
||||||
|
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||||
|
if (ids != null && ids.Count > 0)
|
||||||
|
{
|
||||||
|
return Ok(await _unitOfWork.SeriesRepository.GetAllLanguagesForLibrariesAsync(ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new List<LanguageDto>()
|
||||||
|
{
|
||||||
|
new ()
|
||||||
|
{
|
||||||
|
Title = CultureInfo.GetCultureInfo("en").DisplayName,
|
||||||
|
IsoCode = "en"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using API.DTOs.Metadata;
|
||||||
|
using API.Entities;
|
||||||
|
|
||||||
namespace API.DTOs
|
namespace API.DTOs
|
||||||
{
|
{
|
||||||
@ -68,5 +70,7 @@ namespace API.DTOs
|
|||||||
public ICollection<PersonDto> CoverArtist { get; set; } = new List<PersonDto>();
|
public ICollection<PersonDto> CoverArtist { get; set; } = new List<PersonDto>();
|
||||||
public ICollection<PersonDto> Editor { get; set; } = new List<PersonDto>();
|
public ICollection<PersonDto> Editor { get; set; } = new List<PersonDto>();
|
||||||
public ICollection<PersonDto> Publisher { get; set; } = new List<PersonDto>();
|
public ICollection<PersonDto> Publisher { get; set; } = new List<PersonDto>();
|
||||||
|
public ICollection<PersonDto> Translators { get; set; } = new List<PersonDto>();
|
||||||
|
public ICollection<TagDto> Tags { get; set; } = new List<TagDto>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
using System.Collections;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
|
||||||
using API.Data.Migrations;
|
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
|
||||||
@ -63,14 +61,34 @@ namespace API.DTOs.Filtering
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public IList<int> Character { get; init; } = new List<int>();
|
public IList<int> Character { get; init; } = new List<int>();
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// A list of Translator ids to restrict search to. Defaults to all genres by passing an empty list
|
||||||
|
/// </summary>
|
||||||
|
public IList<int> Translators { get; init; } = new List<int>();
|
||||||
|
/// <summary>
|
||||||
/// A list of Collection Tag ids to restrict search to. Defaults to all genres by passing an empty list
|
/// A list of Collection Tag ids to restrict search to. Defaults to all genres by passing an empty list
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IList<int> CollectionTags { get; init; } = new List<int>();
|
public IList<int> CollectionTags { get; init; } = new List<int>();
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// A list of Tag ids to restrict search to. Defaults to all genres by passing an empty list
|
||||||
|
/// </summary>
|
||||||
|
public IList<int> Tags { get; init; } = new List<int>();
|
||||||
|
/// <summary>
|
||||||
/// Will return back everything with the rating and above
|
/// Will return back everything with the rating and above
|
||||||
/// <see cref="AppUserRating.Rating"/>
|
/// <see cref="AppUserRating.Rating"/>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Rating { get; init; }
|
public int Rating { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Sorting Options for a query. Defaults to null, which uses the queries natural sorting order
|
||||||
|
/// </summary>
|
||||||
|
public SortOptions SortOptions { get; init; } = null;
|
||||||
|
/// <summary>
|
||||||
|
/// Age Ratings. Empty list will return everything back
|
||||||
|
/// </summary>
|
||||||
|
public IList<AgeRating> AgeRating { get; init; } = new List<AgeRating>();
|
||||||
|
/// <summary>
|
||||||
|
/// Languages (ISO 639-1 code) to filter by. Empty list will return everything back
|
||||||
|
/// </summary>
|
||||||
|
public IList<string> Languages { get; init; } = new List<string>();
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
7
API/DTOs/Filtering/LanguageDto.cs
Normal file
7
API/DTOs/Filtering/LanguageDto.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace API.DTOs.Filtering;
|
||||||
|
|
||||||
|
public class LanguageDto
|
||||||
|
{
|
||||||
|
public string IsoCode { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
}
|
8
API/DTOs/Filtering/SortField.cs
Normal file
8
API/DTOs/Filtering/SortField.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace API.DTOs.Filtering;
|
||||||
|
|
||||||
|
public enum SortField
|
||||||
|
{
|
||||||
|
SortName = 1,
|
||||||
|
CreatedDate = 2,
|
||||||
|
LastModifiedDate = 3,
|
||||||
|
}
|
10
API/DTOs/Filtering/SortOptions.cs
Normal file
10
API/DTOs/Filtering/SortOptions.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace API.DTOs.Filtering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sorting Options for a query
|
||||||
|
/// </summary>
|
||||||
|
public class SortOptions
|
||||||
|
{
|
||||||
|
public SortField SortField { get; set; }
|
||||||
|
public bool IsAscending { get; set; } = true;
|
||||||
|
}
|
9
API/DTOs/Metadata/AgeRatingDto.cs
Normal file
9
API/DTOs/Metadata/AgeRatingDto.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using API.Entities.Enums;
|
||||||
|
|
||||||
|
namespace API.DTOs.Metadata;
|
||||||
|
|
||||||
|
public class AgeRatingDto
|
||||||
|
{
|
||||||
|
public AgeRating Value { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
}
|
@ -4,6 +4,5 @@
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
7
API/DTOs/Metadata/TagDto.cs
Normal file
7
API/DTOs/Metadata/TagDto.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace API.DTOs.Metadata;
|
||||||
|
|
||||||
|
public class TagDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
}
|
@ -9,8 +9,18 @@ namespace API.DTOs
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Summary { get; set; }
|
public string Summary { get; set; }
|
||||||
public ICollection<CollectionTagDto> Tags { get; set; }
|
/// <summary>
|
||||||
|
/// Collections the Series belongs to
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<CollectionTagDto> CollectionTags { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Genres for the Series
|
||||||
|
/// </summary>
|
||||||
public ICollection<GenreTagDto> Genres { get; set; }
|
public ICollection<GenreTagDto> Genres { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Collection of all Tags from underlying chapters for a Series
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<TagDto> Tags { get; set; }
|
||||||
public ICollection<PersonDto> Writers { get; set; } = new List<PersonDto>();
|
public ICollection<PersonDto> Writers { get; set; } = new List<PersonDto>();
|
||||||
public ICollection<PersonDto> Artists { get; set; } = new List<PersonDto>();
|
public ICollection<PersonDto> Artists { get; set; } = new List<PersonDto>();
|
||||||
public ICollection<PersonDto> Publishers { get; set; } = new List<PersonDto>();
|
public ICollection<PersonDto> Publishers { get; set; } = new List<PersonDto>();
|
||||||
@ -20,6 +30,7 @@ namespace API.DTOs
|
|||||||
public ICollection<PersonDto> Colorists { get; set; } = new List<PersonDto>();
|
public ICollection<PersonDto> Colorists { get; set; } = new List<PersonDto>();
|
||||||
public ICollection<PersonDto> Letterers { get; set; } = new List<PersonDto>();
|
public ICollection<PersonDto> Letterers { get; set; } = new List<PersonDto>();
|
||||||
public ICollection<PersonDto> Editors { get; set; } = new List<PersonDto>();
|
public ICollection<PersonDto> Editors { get; set; } = new List<PersonDto>();
|
||||||
|
public ICollection<PersonDto> Translators { get; set; } = new List<PersonDto>();
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Highest Age Rating from all Chapters
|
/// Highest Age Rating from all Chapters
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -28,6 +39,10 @@ namespace API.DTOs
|
|||||||
/// Earliest Year from all chapters
|
/// Earliest Year from all chapters
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReleaseYear { get; set; }
|
public int ReleaseYear { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Language of the content (ISO 639-1 code)
|
||||||
|
/// </summary>
|
||||||
|
public string Language { get; set; } = string.Empty;
|
||||||
|
|
||||||
public int SeriesId { get; set; }
|
public int SeriesId { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -39,6 +39,7 @@ namespace API.Data
|
|||||||
public DbSet<ReadingListItem> ReadingListItem { get; set; }
|
public DbSet<ReadingListItem> ReadingListItem { get; set; }
|
||||||
public DbSet<Person> Person { get; set; }
|
public DbSet<Person> Person { get; set; }
|
||||||
public DbSet<Genre> Genre { get; set; }
|
public DbSet<Genre> Genre { get; set; }
|
||||||
|
public DbSet<Tag> Tag { get; set; }
|
||||||
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
@ -91,6 +91,16 @@ namespace API.Data
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Tag Tag(string name, bool external)
|
||||||
|
{
|
||||||
|
return new Tag()
|
||||||
|
{
|
||||||
|
Title = name.Trim().SentenceCase(),
|
||||||
|
NormalizedTitle = Parser.Parser.Normalize(name),
|
||||||
|
ExternalTag = external
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static Person Person(string name, PersonRole role)
|
public static Person Person(string name, PersonRole role)
|
||||||
{
|
{
|
||||||
return new Person()
|
return new Person()
|
||||||
|
@ -20,6 +20,9 @@ namespace API.Data.Metadata
|
|||||||
public string Genre { get; set; } = string.Empty;
|
public string Genre { get; set; } = string.Empty;
|
||||||
public int PageCount { get; set; }
|
public int PageCount { get; set; }
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
|
/// <summary>
|
||||||
|
/// ISO 639-1 Code to represent the language of the content
|
||||||
|
/// </summary>
|
||||||
public string LanguageISO { get; set; } = string.Empty;
|
public string LanguageISO { get; set; } = string.Empty;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This is the link to where the data was scraped from
|
/// This is the link to where the data was scraped from
|
||||||
@ -51,8 +54,16 @@ namespace API.Data.Metadata
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string TitleSort { get; set; } = string.Empty;
|
public string TitleSort { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The translator, can be comma separated. This is part of ComicInfo.xml draft v2.1
|
||||||
|
/// </summary>
|
||||||
|
/// See https://github.com/anansi-project/comicinfo/issues/2 for information about this tag
|
||||||
|
public string Translator { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Misc tags. This is part of ComicInfo.xml draft v2.1
|
||||||
|
/// </summary>
|
||||||
|
/// See https://github.com/anansi-project/comicinfo/issues/1 for information about this tag
|
||||||
|
public string Tags { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This is the Author. For Books, we map creator tag in OPF to this field. Comma separated if multiple.
|
/// This is the Author. For Books, we map creator tag in OPF to this field. Comma separated if multiple.
|
||||||
|
1311
API/Data/Migrations/20211216150752_seriesAndChapterTags.Designer.cs
generated
Normal file
1311
API/Data/Migrations/20211216150752_seriesAndChapterTags.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
103
API/Data/Migrations/20211216150752_seriesAndChapterTags.cs
Normal file
103
API/Data/Migrations/20211216150752_seriesAndChapterTags.cs
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
public partial class seriesAndChapterTags : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Tag",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Title = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
NormalizedTitle = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ExternalTag = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Tag", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ChapterTag",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ChaptersId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TagsId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ChapterTag", x => new { x.ChaptersId, x.TagsId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ChapterTag_Chapter_ChaptersId",
|
||||||
|
column: x => x.ChaptersId,
|
||||||
|
principalTable: "Chapter",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ChapterTag_Tag_TagsId",
|
||||||
|
column: x => x.TagsId,
|
||||||
|
principalTable: "Tag",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SeriesMetadataTag",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SeriesMetadatasId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TagsId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SeriesMetadataTag", x => new { x.SeriesMetadatasId, x.TagsId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SeriesMetadataTag_SeriesMetadata_SeriesMetadatasId",
|
||||||
|
column: x => x.SeriesMetadatasId,
|
||||||
|
principalTable: "SeriesMetadata",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SeriesMetadataTag_Tag_TagsId",
|
||||||
|
column: x => x.TagsId,
|
||||||
|
principalTable: "Tag",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ChapterTag_TagsId",
|
||||||
|
table: "ChapterTag",
|
||||||
|
column: "TagsId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SeriesMetadataTag_TagsId",
|
||||||
|
table: "SeriesMetadataTag",
|
||||||
|
column: "TagsId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tag_NormalizedTitle_ExternalTag",
|
||||||
|
table: "Tag",
|
||||||
|
columns: new[] { "NormalizedTitle", "ExternalTag" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ChapterTag");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SeriesMetadataTag");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Tag");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1314
API/Data/Migrations/20211216191436_seriesLanguage.Designer.cs
generated
Normal file
1314
API/Data/Migrations/20211216191436_seriesLanguage.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
API/Data/Migrations/20211216191436_seriesLanguage.cs
Normal file
25
API/Data/Migrations/20211216191436_seriesLanguage.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
public partial class seriesLanguage : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Language",
|
||||||
|
table: "SeriesMetadata",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Language",
|
||||||
|
table: "SeriesMetadata");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.0");
|
modelBuilder.HasAnnotation("ProductVersion", "6.0.1");
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||||
{
|
{
|
||||||
@ -490,6 +490,9 @@ namespace API.Data.Migrations
|
|||||||
b.Property<int>("AgeRating")
|
b.Property<int>("AgeRating")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int>("ReleaseYear")
|
b.Property<int>("ReleaseYear")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@ -668,6 +671,29 @@ namespace API.Data.Migrations
|
|||||||
b.ToTable("ServerSetting");
|
b.ToTable("ServerSetting");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.Tag", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("ExternalTag")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedTitle")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedTitle", "ExternalTag")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Tag");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Volume", b =>
|
modelBuilder.Entity("API.Entities.Volume", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@ -732,6 +758,21 @@ namespace API.Data.Migrations
|
|||||||
b.ToTable("ChapterPerson");
|
b.ToTable("ChapterPerson");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ChapterTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ChaptersId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TagsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("ChaptersId", "TagsId");
|
||||||
|
|
||||||
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
|
b.ToTable("ChapterTag");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
|
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("CollectionTagsId")
|
b.Property<int>("CollectionTagsId")
|
||||||
@ -861,6 +902,21 @@ namespace API.Data.Migrations
|
|||||||
b.ToTable("PersonSeriesMetadata");
|
b.ToTable("PersonSeriesMetadata");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SeriesMetadataTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesMetadatasId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TagsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("SeriesMetadatasId", "TagsId");
|
||||||
|
|
||||||
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesMetadataTag");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||||
@ -1082,6 +1138,21 @@ namespace API.Data.Migrations
|
|||||||
.IsRequired();
|
.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 =>
|
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.CollectionTag", null)
|
b.HasOne("API.Entities.CollectionTag", null)
|
||||||
@ -1163,6 +1234,21 @@ namespace API.Data.Migrations
|
|||||||
.IsRequired();
|
.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 =>
|
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("UserRoles");
|
b.Navigation("UserRoles");
|
||||||
|
@ -17,6 +17,7 @@ public interface IGenreRepository
|
|||||||
Task<IList<Genre>> GetAllGenresAsync();
|
Task<IList<Genre>> GetAllGenresAsync();
|
||||||
Task<IList<GenreTagDto>> GetAllGenreDtosAsync();
|
Task<IList<GenreTagDto>> GetAllGenreDtosAsync();
|
||||||
Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false);
|
Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false);
|
||||||
|
Task<IList<GenreTagDto>> GetAllGenreDtosForLibrariesAsync(IList<int> libraryIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class GenreRepository : IGenreRepository
|
public class GenreRepository : IGenreRepository
|
||||||
@ -60,6 +61,16 @@ public class GenreRepository : IGenreRepository
|
|||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<GenreTagDto>> GetAllGenreDtosForLibrariesAsync(IList<int> libraryIds)
|
||||||
|
{
|
||||||
|
return await _context.Series
|
||||||
|
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||||
|
.SelectMany(s => s.Metadata.Genres)
|
||||||
|
.Distinct()
|
||||||
|
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IList<Genre>> GetAllGenresAsync()
|
public async Task<IList<Genre>> GetAllGenresAsync()
|
||||||
{
|
{
|
||||||
return await _context.Genre.ToListAsync();
|
return await _context.Genre.ToListAsync();
|
||||||
@ -68,6 +79,7 @@ public class GenreRepository : IGenreRepository
|
|||||||
public async Task<IList<GenreTagDto>> GetAllGenreDtosAsync()
|
public async Task<IList<GenreTagDto>> GetAllGenreDtosAsync()
|
||||||
{
|
{
|
||||||
return await _context.Genre
|
return await _context.Genre
|
||||||
|
.AsNoTracking()
|
||||||
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using API.DTOs;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
|
using AutoMapper.QueryableExtensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace API.Data.Repositories;
|
namespace API.Data.Repositories;
|
||||||
@ -13,6 +15,7 @@ public interface IPersonRepository
|
|||||||
void Remove(Person person);
|
void Remove(Person person);
|
||||||
Task<IList<Person>> GetAllPeople();
|
Task<IList<Person>> GetAllPeople();
|
||||||
Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false);
|
Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false);
|
||||||
|
Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(List<int> libraryIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PersonRepository : IPersonRepository
|
public class PersonRepository : IPersonRepository
|
||||||
@ -57,6 +60,16 @@ public class PersonRepository : IPersonRepository
|
|||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(List<int> libraryIds)
|
||||||
|
{
|
||||||
|
return await _context.Series
|
||||||
|
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||||
|
.SelectMany(s => s.Metadata.People)
|
||||||
|
.Distinct()
|
||||||
|
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<IList<Person>> GetAllPeople()
|
public async Task<IList<Person>> GetAllPeople()
|
||||||
{
|
{
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data.Scanner;
|
using API.Data.Scanner;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.CollectionTags;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
|
using API.DTOs.Metadata;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
@ -14,6 +16,7 @@ using API.Helpers;
|
|||||||
using API.Services.Tasks;
|
using API.Services.Tasks;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using AutoMapper.QueryableExtensions;
|
using AutoMapper.QueryableExtensions;
|
||||||
|
using Kavita.Common.Extensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace API.Data.Repositories;
|
namespace API.Data.Repositories;
|
||||||
@ -67,6 +70,8 @@ public interface ISeriesRepository
|
|||||||
Task<Series> GetFullSeriesForSeriesIdAsync(int seriesId);
|
Task<Series> GetFullSeriesForSeriesIdAsync(int seriesId);
|
||||||
Task<Chunk> GetChunkInfo(int libraryId = 0);
|
Task<Chunk> GetChunkInfo(int libraryId = 0);
|
||||||
Task<IList<SeriesMetadata>> GetSeriesMetadataForIdsAsync(IEnumerable<int> seriesIds);
|
Task<IList<SeriesMetadata>> GetSeriesMetadataForIdsAsync(IEnumerable<int> seriesIds);
|
||||||
|
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
|
||||||
|
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SeriesRepository : ISeriesRepository
|
public class SeriesRepository : ISeriesRepository
|
||||||
@ -135,13 +140,23 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
{
|
{
|
||||||
var query = _context.Series
|
var query = _context.Series
|
||||||
.Where(s => s.LibraryId == libraryId)
|
.Where(s => s.LibraryId == libraryId)
|
||||||
|
|
||||||
.Include(s => s.Metadata)
|
.Include(s => s.Metadata)
|
||||||
.ThenInclude(m => m.People)
|
.ThenInclude(m => m.People)
|
||||||
|
|
||||||
.Include(s => s.Metadata)
|
.Include(s => s.Metadata)
|
||||||
.ThenInclude(m => m.Genres)
|
.ThenInclude(m => m.Genres)
|
||||||
|
|
||||||
|
.Include(s => s.Metadata)
|
||||||
|
.ThenInclude(m => m.Tags)
|
||||||
|
|
||||||
.Include(s => s.Volumes)
|
.Include(s => s.Volumes)
|
||||||
.ThenInclude(v => v.Chapters)
|
.ThenInclude(v => v.Chapters)
|
||||||
.ThenInclude(cm => cm.People)
|
.ThenInclude(cm => cm.People)
|
||||||
|
|
||||||
|
.Include(s => s.Volumes)
|
||||||
|
.ThenInclude(v => v.Chapters)
|
||||||
|
|
||||||
.Include(s => s.Volumes)
|
.Include(s => s.Volumes)
|
||||||
.ThenInclude(v => v.Chapters)
|
.ThenInclude(v => v.Chapters)
|
||||||
.ThenInclude(c => c.Files)
|
.ThenInclude(c => c.Files)
|
||||||
@ -168,6 +183,14 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
.Include(s => s.Volumes)
|
.Include(s => s.Volumes)
|
||||||
.ThenInclude(v => v.Chapters)
|
.ThenInclude(v => v.Chapters)
|
||||||
.ThenInclude(cm => cm.People)
|
.ThenInclude(cm => cm.People)
|
||||||
|
|
||||||
|
.Include(s => s.Volumes)
|
||||||
|
.ThenInclude(v => v.Chapters)
|
||||||
|
.ThenInclude(cm => cm.Tags)
|
||||||
|
|
||||||
|
.Include(s => s.Metadata)
|
||||||
|
.ThenInclude(m => m.Tags)
|
||||||
|
|
||||||
.Include(s => s.Volumes)
|
.Include(s => s.Volumes)
|
||||||
.ThenInclude(v => v.Chapters)
|
.ThenInclude(v => v.Chapters)
|
||||||
.ThenInclude(c => c.Files)
|
.ThenInclude(c => c.Files)
|
||||||
@ -179,8 +202,12 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
{
|
{
|
||||||
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter);
|
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter);
|
||||||
|
|
||||||
|
if (filter.SortOptions == null)
|
||||||
|
{
|
||||||
|
query = query.OrderBy(s => s.SortName);
|
||||||
|
}
|
||||||
|
|
||||||
var retSeries = query
|
var retSeries = query
|
||||||
.OrderByDescending(s => s.SortName)
|
|
||||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.AsNoTracking();
|
.AsNoTracking();
|
||||||
@ -387,7 +414,8 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
|
|
||||||
private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries,
|
private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries,
|
||||||
out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter,
|
out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter,
|
||||||
out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds)
|
out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds, out bool hasAgeRating, out bool hasTagsFilter,
|
||||||
|
out bool hasLanguageFilter)
|
||||||
{
|
{
|
||||||
var formats = filter.GetSqlFilter();
|
var formats = filter.GetSqlFilter();
|
||||||
|
|
||||||
@ -406,12 +434,16 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
allPeopleIds.AddRange(filter.Penciller);
|
allPeopleIds.AddRange(filter.Penciller);
|
||||||
allPeopleIds.AddRange(filter.Publisher);
|
allPeopleIds.AddRange(filter.Publisher);
|
||||||
allPeopleIds.AddRange(filter.CoverArtist);
|
allPeopleIds.AddRange(filter.CoverArtist);
|
||||||
|
allPeopleIds.AddRange(filter.Translators);
|
||||||
|
|
||||||
hasPeopleFilter = allPeopleIds.Count > 0;
|
hasPeopleFilter = allPeopleIds.Count > 0;
|
||||||
hasGenresFilter = filter.Genres.Count > 0;
|
hasGenresFilter = filter.Genres.Count > 0;
|
||||||
hasCollectionTagFilter = filter.CollectionTags.Count > 0;
|
hasCollectionTagFilter = filter.CollectionTags.Count > 0;
|
||||||
hasRatingFilter = filter.Rating > 0;
|
hasRatingFilter = filter.Rating > 0;
|
||||||
hasProgressFilter = !filter.ReadStatus.Read || !filter.ReadStatus.InProgress || !filter.ReadStatus.NotRead;
|
hasProgressFilter = !filter.ReadStatus.Read || !filter.ReadStatus.InProgress || !filter.ReadStatus.NotRead;
|
||||||
|
hasAgeRating = filter.AgeRating.Count > 0;
|
||||||
|
hasTagsFilter = filter.Tags.Count > 0;
|
||||||
|
hasLanguageFilter = filter.Languages.Count > 0;
|
||||||
|
|
||||||
|
|
||||||
bool ProgressComparison(int pagesRead, int totalPages)
|
bool ProgressComparison(int pagesRead, int totalPages)
|
||||||
@ -499,7 +531,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
|
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
|
||||||
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
|
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
|
||||||
out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
|
out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
|
||||||
out var seriesIds);
|
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter);
|
||||||
|
|
||||||
var query = _context.Series
|
var query = _context.Series
|
||||||
.Where(s => userLibraries.Contains(s.LibraryId)
|
.Where(s => userLibraries.Contains(s.LibraryId)
|
||||||
@ -510,42 +542,41 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id)))
|
s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id)))
|
||||||
&& (!hasRatingFilter || s.Ratings.Any(r => r.Rating >= filter.Rating))
|
&& (!hasRatingFilter || s.Ratings.Any(r => r.Rating >= filter.Rating))
|
||||||
&& (!hasProgressFilter || seriesIds.Contains(s.Id))
|
&& (!hasProgressFilter || seriesIds.Contains(s.Id))
|
||||||
|
&& (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating))
|
||||||
|
&& (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id)))
|
||||||
|
&& (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language))
|
||||||
)
|
)
|
||||||
.AsNoTracking();
|
.AsNoTracking();
|
||||||
// IQueryable<FilterableQuery> newFilter = null;
|
|
||||||
// if (hasProgressFilter)
|
|
||||||
// {
|
|
||||||
// newFilter = query
|
|
||||||
// .Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) =>
|
|
||||||
// new
|
|
||||||
// {
|
|
||||||
// Series = s,
|
|
||||||
// PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId)
|
|
||||||
// .Sum(s1 => s1.PagesRead),
|
|
||||||
// progress.AppUserId,
|
|
||||||
// LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id && p.AppUserId == userId)
|
|
||||||
// .Max(p => p.LastModified)
|
|
||||||
// })
|
|
||||||
// .Select(d => new FilterableQuery()
|
|
||||||
// {
|
|
||||||
// Series = d.Series,
|
|
||||||
// AppUserId = d.AppUserId,
|
|
||||||
// LastModified = d.LastModified,
|
|
||||||
// PagesRead = d.PagesRead
|
|
||||||
// })
|
|
||||||
// .Where(d => seriesIds.Contains(d.Series.Id));
|
|
||||||
// }
|
|
||||||
// else
|
|
||||||
// {
|
|
||||||
// newFilter = query.Select(s => new FilterableQuery()
|
|
||||||
// {
|
|
||||||
// Series = s,
|
|
||||||
// LastModified = DateTime.Now, // TODO: Figure this out
|
|
||||||
// AppUserId = userId,
|
|
||||||
// PagesRead = 0
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
if (filter.SortOptions != null)
|
||||||
|
{
|
||||||
|
if (filter.SortOptions.IsAscending)
|
||||||
|
{
|
||||||
|
if (filter.SortOptions.SortField == SortField.SortName)
|
||||||
|
{
|
||||||
|
query = query.OrderBy(s => s.SortName);
|
||||||
|
} else if (filter.SortOptions.SortField == SortField.CreatedDate)
|
||||||
|
{
|
||||||
|
query = query.OrderBy(s => s.Created);
|
||||||
|
} else if (filter.SortOptions.SortField == SortField.LastModifiedDate)
|
||||||
|
{
|
||||||
|
query = query.OrderBy(s => s.LastModified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (filter.SortOptions.SortField == SortField.SortName)
|
||||||
|
{
|
||||||
|
query = query.OrderByDescending(s => s.SortName);
|
||||||
|
} else if (filter.SortOptions.SortField == SortField.CreatedDate)
|
||||||
|
{
|
||||||
|
query = query.OrderByDescending(s => s.Created);
|
||||||
|
} else if (filter.SortOptions.SortField == SortField.LastModifiedDate)
|
||||||
|
{
|
||||||
|
query = query.OrderByDescending(s => s.LastModified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
@ -555,13 +586,15 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
var metadataDto = await _context.SeriesMetadata
|
var metadataDto = await _context.SeriesMetadata
|
||||||
.Where(metadata => metadata.SeriesId == seriesId)
|
.Where(metadata => metadata.SeriesId == seriesId)
|
||||||
.Include(m => m.Genres)
|
.Include(m => m.Genres)
|
||||||
|
.Include(m => m.Tags)
|
||||||
|
.Include(m => m.People)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
|
||||||
.SingleOrDefaultAsync();
|
.SingleOrDefaultAsync();
|
||||||
|
|
||||||
if (metadataDto != null)
|
if (metadataDto != null)
|
||||||
{
|
{
|
||||||
metadataDto.Tags = await _context.CollectionTag
|
metadataDto.CollectionTags = await _context.CollectionTag
|
||||||
.Include(t => t.SeriesMetadatas)
|
.Include(t => t.SeriesMetadatas)
|
||||||
.Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId))
|
.Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId))
|
||||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
||||||
@ -694,4 +727,35 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
.Include(sm => sm.CollectionTags)
|
.Include(sm => sm.CollectionTags)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds)
|
||||||
|
{
|
||||||
|
return await _context.Series
|
||||||
|
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||||
|
.Select(s => s.Metadata.AgeRating)
|
||||||
|
.Distinct()
|
||||||
|
.Select(s => new AgeRatingDto()
|
||||||
|
{
|
||||||
|
Value = s,
|
||||||
|
Title = s.ToDescription()
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
|
||||||
|
{
|
||||||
|
var ret = await _context.Series
|
||||||
|
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||||
|
.Select(s => s.Metadata.Language)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return ret
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
|
.Select(s => new LanguageDto()
|
||||||
|
{
|
||||||
|
Title = CultureInfo.GetCultureInfo(s).DisplayName,
|
||||||
|
IsoCode = s
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
86
API/Data/Repositories/TagRepository.cs
Normal file
86
API/Data/Repositories/TagRepository.cs
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using API.DTOs.Metadata;
|
||||||
|
using API.Entities;
|
||||||
|
using AutoMapper;
|
||||||
|
using AutoMapper.QueryableExtensions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Data.Repositories;
|
||||||
|
|
||||||
|
public interface ITagRepository
|
||||||
|
{
|
||||||
|
void Attach(Tag tag);
|
||||||
|
void Remove(Tag tag);
|
||||||
|
Task<Tag> FindByNameAsync(string tagName);
|
||||||
|
Task<IList<Tag>> GetAllTagsAsync();
|
||||||
|
Task<IList<TagDto>> GetAllTagDtosAsync();
|
||||||
|
Task RemoveAllTagNoLongerAssociated(bool removeExternal = false);
|
||||||
|
Task<IList<TagDto>> GetAllTagDtosForLibrariesAsync(IList<int> libraryIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TagRepository : ITagRepository
|
||||||
|
{
|
||||||
|
private readonly DataContext _context;
|
||||||
|
private readonly IMapper _mapper;
|
||||||
|
|
||||||
|
public TagRepository(DataContext context, IMapper mapper)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Attach(Tag tag)
|
||||||
|
{
|
||||||
|
_context.Tag.Attach(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove(Tag tag)
|
||||||
|
{
|
||||||
|
_context.Tag.Remove(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Tag> FindByNameAsync(string tagName)
|
||||||
|
{
|
||||||
|
var normalizedName = Parser.Parser.Normalize(tagName);
|
||||||
|
return await _context.Tag
|
||||||
|
.FirstOrDefaultAsync(g => g.NormalizedTitle.Equals(normalizedName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveAllTagNoLongerAssociated(bool removeExternal = false)
|
||||||
|
{
|
||||||
|
var TagsWithNoConnections = await _context.Tag
|
||||||
|
.Include(p => p.SeriesMetadatas)
|
||||||
|
.Include(p => p.Chapters)
|
||||||
|
.Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
_context.Tag.RemoveRange(TagsWithNoConnections);
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IList<TagDto>> GetAllTagDtosForLibrariesAsync(IList<int> libraryIds)
|
||||||
|
{
|
||||||
|
return await _context.Series
|
||||||
|
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||||
|
.SelectMany(s => s.Metadata.Tags)
|
||||||
|
.Distinct()
|
||||||
|
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IList<Tag>> GetAllTagsAsync()
|
||||||
|
{
|
||||||
|
return await _context.Tag.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IList<TagDto>> GetAllTagDtosAsync()
|
||||||
|
{
|
||||||
|
return await _context.Tag
|
||||||
|
.AsNoTracking()
|
||||||
|
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
@ -173,6 +173,8 @@ public class VolumeRepository : IVolumeRepository
|
|||||||
.Where(vol => vol.SeriesId == seriesId)
|
.Where(vol => vol.SeriesId == seriesId)
|
||||||
.Include(vol => vol.Chapters)
|
.Include(vol => vol.Chapters)
|
||||||
.ThenInclude(c => c.People)
|
.ThenInclude(c => c.People)
|
||||||
|
.Include(vol => vol.Chapters)
|
||||||
|
.ThenInclude(c => c.Tags)
|
||||||
.OrderBy(volume => volume.Number)
|
.OrderBy(volume => volume.Number)
|
||||||
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
@ -20,6 +20,7 @@ public interface IUnitOfWork
|
|||||||
ISeriesMetadataRepository SeriesMetadataRepository { get; }
|
ISeriesMetadataRepository SeriesMetadataRepository { get; }
|
||||||
IPersonRepository PersonRepository { get; }
|
IPersonRepository PersonRepository { get; }
|
||||||
IGenreRepository GenreRepository { get; }
|
IGenreRepository GenreRepository { get; }
|
||||||
|
ITagRepository TagRepository { get; }
|
||||||
bool Commit();
|
bool Commit();
|
||||||
Task<bool> CommitAsync();
|
Task<bool> CommitAsync();
|
||||||
bool HasChanges();
|
bool HasChanges();
|
||||||
@ -54,6 +55,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public ISeriesMetadataRepository SeriesMetadataRepository => new SeriesMetadataRepository(_context);
|
public ISeriesMetadataRepository SeriesMetadataRepository => new SeriesMetadataRepository(_context);
|
||||||
public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper);
|
public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper);
|
||||||
public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper);
|
public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper);
|
||||||
|
public ITagRepository TagRepository => new TagRepository(_context, _mapper);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Commits changes to the DB. Completes the open transaction.
|
/// Commits changes to the DB. Completes the open transaction.
|
||||||
|
@ -62,6 +62,7 @@ namespace API.Entities
|
|||||||
/// All people attached at a Chapter level. Usually Comics will have different people per issue.
|
/// All people attached at a Chapter level. Usually Comics will have different people per issue.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ICollection<Person> People { get; set; } = new List<Person>();
|
public ICollection<Person> People { get; set; } = new List<Person>();
|
||||||
|
public ICollection<Tag> Tags { get; set; } = new List<Tag>();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -24,7 +24,11 @@
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a character/person within the story
|
/// Represents a character/person within the story
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Character = 11
|
Character = 11,
|
||||||
|
/// <summary>
|
||||||
|
/// The Translator
|
||||||
|
/// </summary>
|
||||||
|
Translator = 12
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Interfaces;
|
using API.Entities.Interfaces;
|
||||||
@ -17,6 +18,7 @@ namespace API.Entities.Metadata
|
|||||||
public ICollection<CollectionTag> CollectionTags { get; set; }
|
public ICollection<CollectionTag> CollectionTags { get; set; }
|
||||||
|
|
||||||
public ICollection<Genre> Genres { get; set; } = new List<Genre>();
|
public ICollection<Genre> Genres { get; set; } = new List<Genre>();
|
||||||
|
public ICollection<Tag> Tags { get; set; } = new List<Tag>();
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All people attached at a Series level.
|
/// All people attached at a Series level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -30,6 +32,10 @@ namespace API.Entities.Metadata
|
|||||||
/// Earliest Year from all chapters
|
/// Earliest Year from all chapters
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReleaseYear { get; set; }
|
public int ReleaseYear { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Language of the content (ISO 639-1 code)
|
||||||
|
/// </summary>
|
||||||
|
public string Language { get; set; } = string.Empty;
|
||||||
|
|
||||||
// Relationship
|
// Relationship
|
||||||
public Series Series { get; set; }
|
public Series Series { get; set; }
|
||||||
|
17
API/Entities/Tag.cs
Normal file
17
API/Entities/Tag.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Entities.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Entities;
|
||||||
|
|
||||||
|
[Index(nameof(NormalizedTitle), nameof(ExternalTag), IsUnique = true)]
|
||||||
|
public class Tag
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public string NormalizedTitle { get; set; }
|
||||||
|
public bool ExternalTag { get; set; }
|
||||||
|
|
||||||
|
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; }
|
||||||
|
public ICollection<Chapter> Chapters { get; set; }
|
||||||
|
}
|
@ -48,15 +48,16 @@ namespace API.Helpers
|
|||||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher)))
|
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher)))
|
||||||
.ForMember(dest => dest.Editor,
|
.ForMember(dest => dest.Editor,
|
||||||
opt =>
|
opt =>
|
||||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)));
|
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)))
|
||||||
|
.ForMember(dest => dest.Translators,
|
||||||
|
opt =>
|
||||||
|
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator)));
|
||||||
|
|
||||||
CreateMap<Series, SeriesDto>();
|
CreateMap<Series, SeriesDto>();
|
||||||
|
|
||||||
CreateMap<CollectionTag, CollectionTagDto>();
|
CreateMap<CollectionTag, CollectionTagDto>();
|
||||||
|
|
||||||
CreateMap<Person, PersonDto>();
|
CreateMap<Person, PersonDto>();
|
||||||
|
|
||||||
CreateMap<Genre, GenreTagDto>();
|
CreateMap<Genre, GenreTagDto>();
|
||||||
|
CreateMap<Tag, TagDto>();
|
||||||
|
|
||||||
CreateMap<SeriesMetadata, SeriesMetadataDto>()
|
CreateMap<SeriesMetadata, SeriesMetadataDto>()
|
||||||
.ForMember(dest => dest.Writers,
|
.ForMember(dest => dest.Writers,
|
||||||
@ -83,6 +84,9 @@ namespace API.Helpers
|
|||||||
.ForMember(dest => dest.Pencillers,
|
.ForMember(dest => dest.Pencillers,
|
||||||
opt =>
|
opt =>
|
||||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller)))
|
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller)))
|
||||||
|
.ForMember(dest => dest.Translators,
|
||||||
|
opt =>
|
||||||
|
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator)))
|
||||||
.ForMember(dest => dest.Editors,
|
.ForMember(dest => dest.Editors,
|
||||||
opt =>
|
opt =>
|
||||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)));
|
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)));
|
||||||
|
@ -11,23 +11,23 @@ public static class GenreHelper
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="allPeople"></param>
|
/// <param name="allGenres"></param>
|
||||||
/// <param name="names"></param>
|
/// <param name="names"></param>
|
||||||
/// <param name="isExternal"></param>
|
/// <param name="isExternal"></param>
|
||||||
/// <param name="action"></param>
|
/// <param name="action"></param>
|
||||||
public static void UpdateGenre(ICollection<Genre> allPeople, IEnumerable<string> names, bool isExternal, Action<Genre> action)
|
public static void UpdateGenre(ICollection<Genre> allGenres, IEnumerable<string> names, bool isExternal, Action<Genre> action)
|
||||||
{
|
{
|
||||||
foreach (var name in names)
|
foreach (var name in names)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(name.Trim())) continue;
|
if (string.IsNullOrEmpty(name.Trim())) continue;
|
||||||
|
|
||||||
var normalizedName = Parser.Parser.Normalize(name);
|
var normalizedName = Parser.Parser.Normalize(name);
|
||||||
var genre = allPeople.FirstOrDefault(p =>
|
var genre = allGenres.FirstOrDefault(p =>
|
||||||
p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal);
|
p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal);
|
||||||
if (genre == null)
|
if (genre == null)
|
||||||
{
|
{
|
||||||
genre = DbFactory.Genre(name, false);
|
genre = DbFactory.Genre(name, false);
|
||||||
allPeople.Add(genre);
|
allGenres.Add(genre);
|
||||||
}
|
}
|
||||||
|
|
||||||
action(genre);
|
action(genre);
|
||||||
|
93
API/Helpers/TagHelper.cs
Normal file
93
API/Helpers/TagHelper.cs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using API.Data;
|
||||||
|
using API.Entities;
|
||||||
|
|
||||||
|
namespace API.Helpers;
|
||||||
|
|
||||||
|
public static class TagHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="allTags"></param>
|
||||||
|
/// <param name="names"></param>
|
||||||
|
/// <param name="isExternal"></param>
|
||||||
|
/// <param name="action">Callback for every item. Will give said item back and a bool if item was added</param>
|
||||||
|
public static void UpdateTag(ICollection<Tag> allTags, IEnumerable<string> names, bool isExternal, Action<Tag, bool> action)
|
||||||
|
{
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(name.Trim())) continue;
|
||||||
|
|
||||||
|
var added = false;
|
||||||
|
var normalizedName = Parser.Parser.Normalize(name);
|
||||||
|
|
||||||
|
// var tag = DbFactory.Tag(name, isExternal);
|
||||||
|
// TagHelper.AddTagIfNotExists(allTags, tag);
|
||||||
|
|
||||||
|
var genre = allTags.FirstOrDefault(p =>
|
||||||
|
p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal);
|
||||||
|
if (genre == null)
|
||||||
|
{
|
||||||
|
added = true;
|
||||||
|
genre = DbFactory.Tag(name, false);
|
||||||
|
allTags.Add(genre);
|
||||||
|
}
|
||||||
|
|
||||||
|
action(genre, added);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void KeepOnlySameTagBetweenLists(ICollection<Tag> existingTags, ICollection<Tag> removeAllExcept, Action<Tag> action = null)
|
||||||
|
{
|
||||||
|
var existing = existingTags.ToList();
|
||||||
|
foreach (var genre in existing)
|
||||||
|
{
|
||||||
|
var existingPerson = removeAllExcept.FirstOrDefault(g => g.ExternalTag == genre.ExternalTag && genre.NormalizedTitle.Equals(g.NormalizedTitle));
|
||||||
|
if (existingPerson != null) continue;
|
||||||
|
existingTags.Remove(genre);
|
||||||
|
action?.Invoke(genre);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the tag to the list if it's not already in there. This will ignore the ExternalTag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="metadataTags"></param>
|
||||||
|
/// <param name="tag"></param>
|
||||||
|
public static void AddTagIfNotExists(ICollection<Tag> metadataTags, Tag tag)
|
||||||
|
{
|
||||||
|
var existingGenre = metadataTags.FirstOrDefault(p =>
|
||||||
|
p.NormalizedTitle == Parser.Parser.Normalize(tag.Title));
|
||||||
|
if (existingGenre == null)
|
||||||
|
{
|
||||||
|
metadataTags.Add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove tags on a list
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Used to remove before we update/add new tags</remarks>
|
||||||
|
/// <param name="existingTags">Existing tags on Entity</param>
|
||||||
|
/// <param name="tags">Tags from metadata</param>
|
||||||
|
/// <param name="isExternal">Remove external tags?</param>
|
||||||
|
/// <param name="action">Callback which will be executed for each tag removed</param>
|
||||||
|
public static void RemoveTags(ICollection<Tag> existingTags, IEnumerable<string> tags, bool isExternal, Action<Tag> action = null)
|
||||||
|
{
|
||||||
|
var normalizedTags = tags.Select(Parser.Parser.Normalize).ToList();
|
||||||
|
foreach (var person in normalizedTags)
|
||||||
|
{
|
||||||
|
var existingTag = existingTags.FirstOrDefault(p => p.ExternalTag == isExternal && person.Equals(p.NormalizedTitle));
|
||||||
|
if (existingTag == null) continue;
|
||||||
|
|
||||||
|
existingTags.Remove(existingTag);
|
||||||
|
action?.Invoke(existingTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,16 +76,16 @@ public class MetadataService : IMetadataService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateChapterMetadata(Chapter chapter, ICollection<Person> allPeople, bool forceUpdate)
|
private void UpdateChapterMetadata(Chapter chapter, ICollection<Person> allPeople, ICollection<Tag> allTags, bool forceUpdate)
|
||||||
{
|
{
|
||||||
var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
|
var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
|
||||||
if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return;
|
if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return;
|
||||||
|
|
||||||
UpdateChapterFromComicInfo(chapter, allPeople, firstFile);
|
UpdateChapterFromComicInfo(chapter, allPeople, allTags, firstFile);
|
||||||
firstFile.UpdateLastModified();
|
firstFile.UpdateLastModified();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateChapterFromComicInfo(Chapter chapter, ICollection<Person> allPeople, MangaFile firstFile)
|
private void UpdateChapterFromComicInfo(Chapter chapter, ICollection<Person> allPeople, ICollection<Tag> allTags, MangaFile firstFile)
|
||||||
{
|
{
|
||||||
// TODO: Think about letting the higher level loop have access for series to avoid duplicate IO operations
|
// TODO: Think about letting the higher level loop have access for series to avoid duplicate IO operations
|
||||||
var comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath, firstFile.Format);
|
var comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath, firstFile.Format);
|
||||||
@ -98,7 +98,7 @@ public class MetadataService : IMetadataService
|
|||||||
chapter.TitleName = comicInfo.Title.Trim();
|
chapter.TitleName = comicInfo.Title.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (comicInfo.Year > 0 && comicInfo.Month > 0)
|
if (comicInfo.Year > 0)
|
||||||
{
|
{
|
||||||
var day = Math.Max(comicInfo.Day, 1);
|
var day = Math.Max(comicInfo.Day, 1);
|
||||||
var month = Math.Max(comicInfo.Month, 1);
|
var month = Math.Max(comicInfo.Month, 1);
|
||||||
@ -113,6 +113,26 @@ public class MetadataService : IMetadataService
|
|||||||
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(comicInfo.Translator))
|
||||||
|
{
|
||||||
|
var people = comicInfo.Translator.Split(",");
|
||||||
|
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator);
|
||||||
|
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Translator,
|
||||||
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(comicInfo.Tags))
|
||||||
|
{
|
||||||
|
var tags = comicInfo.Tags.Split(",").Select(s => s.Trim()).ToList();
|
||||||
|
// Remove all tags that aren't matching between chapter tags and metadata
|
||||||
|
TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => DbFactory.Tag(t, false)).ToList());
|
||||||
|
TagHelper.UpdateTag(allTags, tags, false,
|
||||||
|
(tag, added) =>
|
||||||
|
{
|
||||||
|
chapter.Tags.Add(tag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Writer))
|
if (!string.IsNullOrEmpty(comicInfo.Writer))
|
||||||
{
|
{
|
||||||
var people = comicInfo.Writer.Split(",");
|
var people = comicInfo.Writer.Split(",");
|
||||||
@ -198,7 +218,6 @@ public class MetadataService : IMetadataService
|
|||||||
{
|
{
|
||||||
if (series == null) return;
|
if (series == null) return;
|
||||||
|
|
||||||
// NOTE: This will fail if we replace the cover of the first volume on a first scan. Because the series will already have a cover image
|
|
||||||
if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage), null, series.Created, forceUpdate, series.CoverImageLocked))
|
if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage), null, series.Created, forceUpdate, series.CoverImageLocked))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@ -223,7 +242,7 @@ public class MetadataService : IMetadataService
|
|||||||
series.CoverImage = firstCover?.CoverImage ?? coverImage;
|
series.CoverImage = firstCover?.CoverImage ?? coverImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateSeriesMetadata(Series series, ICollection<Person> allPeople, ICollection<Genre> allGenres, bool forceUpdate)
|
private void UpdateSeriesMetadata(Series series, ICollection<Person> allPeople, ICollection<Genre> allGenres, ICollection<Tag> allTags, bool forceUpdate)
|
||||||
{
|
{
|
||||||
var isBook = series.Library.Type == LibraryType.Book;
|
var isBook = series.Library.Type == LibraryType.Book;
|
||||||
var firstVolume = series.Volumes.OrderBy(c => c.Number, new ChapterSortComparer()).FirstWithChapters(isBook);
|
var firstVolume = series.Volumes.OrderBy(c => c.Number, new ChapterSortComparer()).FirstWithChapters(isBook);
|
||||||
@ -233,10 +252,6 @@ public class MetadataService : IMetadataService
|
|||||||
if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(firstChapter, forceUpdate, firstFile)) return;
|
if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(firstChapter, forceUpdate, firstFile)) return;
|
||||||
if (Parser.Parser.IsPdf(firstFile.FilePath)) return;
|
if (Parser.Parser.IsPdf(firstFile.FilePath)) return;
|
||||||
|
|
||||||
var comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath, firstFile.Format);
|
|
||||||
if (comicInfo == null) return;
|
|
||||||
|
|
||||||
|
|
||||||
foreach (var chapter in series.Volumes.SelectMany(volume => volume.Chapters))
|
foreach (var chapter in series.Volumes.SelectMany(volume => volume.Chapters))
|
||||||
{
|
{
|
||||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Name), PersonRole.Writer,
|
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Name), PersonRole.Writer,
|
||||||
@ -265,29 +280,40 @@ public class MetadataService : IMetadataService
|
|||||||
|
|
||||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Penciller).Select(p => p.Name), PersonRole.Penciller,
|
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Penciller).Select(p => p.Name), PersonRole.Penciller,
|
||||||
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||||
|
|
||||||
|
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Translator).Select(p => p.Name), PersonRole.Translator,
|
||||||
|
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||||
|
|
||||||
|
TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, added) =>
|
||||||
|
TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag));
|
||||||
}
|
}
|
||||||
|
|
||||||
var comicInfos = series.Volumes
|
var comicInfos = series.Volumes
|
||||||
.SelectMany(volume => volume.Chapters)
|
.SelectMany(volume => volume.Chapters)
|
||||||
|
.OrderBy(c => double.Parse(c.Number), new ChapterSortComparer())
|
||||||
.SelectMany(c => c.Files)
|
.SelectMany(c => c.Files)
|
||||||
.Select(file => _readingItemService.GetComicInfo(file.FilePath, file.Format))
|
.Select(file => _readingItemService.GetComicInfo(file.FilePath, file.Format))
|
||||||
.Where(ci => ci != null)
|
.Where(ci => ci != null)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
//var firstComicInfo = comicInfos.First(i => i.)
|
var comicInfo = comicInfos.FirstOrDefault();
|
||||||
// Summary Info
|
if (!string.IsNullOrEmpty(comicInfo?.Summary))
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Summary))
|
|
||||||
{
|
{
|
||||||
// PERF: I can move this to the bottom as I have a comicInfo selection, save me an extra read
|
|
||||||
series.Metadata.Summary = comicInfo.Summary;
|
series.Metadata.Summary = comicInfo.Summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(comicInfo?.LanguageISO))
|
||||||
|
{
|
||||||
|
series.Metadata.Language = comicInfo.LanguageISO;
|
||||||
|
}
|
||||||
|
|
||||||
// Set the AgeRating as highest in all the comicInfos
|
// Set the AgeRating as highest in all the comicInfos
|
||||||
series.Metadata.AgeRating = comicInfos.Max(i => ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating));
|
series.Metadata.AgeRating = comicInfos.Max(i => ComicInfo.ConvertAgeRatingToEnum(comicInfo?.AgeRating));
|
||||||
series.Metadata.ReleaseYear = series.Volumes
|
series.Metadata.ReleaseYear = series.Volumes
|
||||||
.SelectMany(volume => volume.Chapters).Min(c => c.ReleaseDate.Year);
|
.SelectMany(volume => volume.Chapters).Min(c => c.ReleaseDate.Year);
|
||||||
|
|
||||||
var genres = comicInfos.SelectMany(i => i?.Genre.Split(",")).Distinct().ToList();
|
var genres = comicInfos.SelectMany(i => i?.Genre.Split(",")).Distinct().ToList();
|
||||||
|
var tags = comicInfos.SelectMany(i => i?.Tags.Split(",")).Distinct().ToList();
|
||||||
var people = series.Volumes.SelectMany(volume => volume.Chapters).SelectMany(c => c.People).ToList();
|
var people = series.Volumes.SelectMany(volume => volume.Chapters).SelectMany(c => c.People).ToList();
|
||||||
|
|
||||||
|
|
||||||
@ -304,7 +330,7 @@ public class MetadataService : IMetadataService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="series"></param>
|
/// <param name="series"></param>
|
||||||
/// <param name="forceUpdate"></param>
|
/// <param name="forceUpdate"></param>
|
||||||
private void ProcessSeriesMetadataUpdate(Series series, ICollection<Person> allPeople, ICollection<Genre> allGenres, bool forceUpdate)
|
private void ProcessSeriesMetadataUpdate(Series series, ICollection<Person> allPeople, ICollection<Genre> allGenres, ICollection<Tag> allTags, bool forceUpdate)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName);
|
_logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName);
|
||||||
try
|
try
|
||||||
@ -316,14 +342,14 @@ public class MetadataService : IMetadataService
|
|||||||
foreach (var chapter in volume.Chapters)
|
foreach (var chapter in volume.Chapters)
|
||||||
{
|
{
|
||||||
chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate);
|
chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate);
|
||||||
UpdateChapterMetadata(chapter, allPeople, forceUpdate || chapterUpdated);
|
UpdateChapterMetadata(chapter, allPeople, allTags, forceUpdate || chapterUpdated);
|
||||||
}
|
}
|
||||||
|
|
||||||
volumeUpdated = UpdateVolumeCoverImage(volume, chapterUpdated || forceUpdate);
|
volumeUpdated = UpdateVolumeCoverImage(volume, chapterUpdated || forceUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateSeriesCoverImage(series, volumeUpdated || forceUpdate);
|
UpdateSeriesCoverImage(series, volumeUpdated || forceUpdate);
|
||||||
UpdateSeriesMetadata(series, allPeople, allGenres, forceUpdate);
|
UpdateSeriesMetadata(series, allPeople, allGenres, allTags, forceUpdate);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -370,6 +396,7 @@ public class MetadataService : IMetadataService
|
|||||||
|
|
||||||
var allPeople = await _unitOfWork.PersonRepository.GetAllPeople();
|
var allPeople = await _unitOfWork.PersonRepository.GetAllPeople();
|
||||||
var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync();
|
var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync();
|
||||||
|
var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync();
|
||||||
|
|
||||||
|
|
||||||
var seriesIndex = 0;
|
var seriesIndex = 0;
|
||||||
@ -377,7 +404,7 @@ public class MetadataService : IMetadataService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ProcessSeriesMetadataUpdate(series, allPeople, allGenres, forceUpdate);
|
ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -404,14 +431,19 @@ public class MetadataService : IMetadataService
|
|||||||
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
|
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
|
||||||
MessageFactory.RefreshMetadataProgressEvent(library.Id, 1F));
|
MessageFactory.RefreshMetadataProgressEvent(library.Id, 1F));
|
||||||
|
|
||||||
// TODO: Remove any leftover People from DB
|
await RemoveAbandonedMetadataKeys();
|
||||||
await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated();
|
|
||||||
await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated();
|
|
||||||
|
|
||||||
|
|
||||||
_logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime);
|
_logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task RemoveAbandonedMetadataKeys()
|
||||||
|
{
|
||||||
|
await _unitOfWork.TagRepository.RemoveAllTagNoLongerAssociated();
|
||||||
|
await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated();
|
||||||
|
await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated();
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: I can probably refactor RefreshMetadata and RefreshMetadataForSeries to be the same by utilizing chunk size of 1, so most of the code can be the same.
|
// TODO: I can probably refactor RefreshMetadata and RefreshMetadataForSeries to be the same by utilizing chunk size of 1, so most of the code can be the same.
|
||||||
private async Task PerformScan(Library library, bool forceUpdate, Action<int, Chunk> action)
|
private async Task PerformScan(Library library, bool forceUpdate, Action<int, Chunk> action)
|
||||||
{
|
{
|
||||||
@ -490,8 +522,9 @@ public class MetadataService : IMetadataService
|
|||||||
|
|
||||||
var allPeople = await _unitOfWork.PersonRepository.GetAllPeople();
|
var allPeople = await _unitOfWork.PersonRepository.GetAllPeople();
|
||||||
var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync();
|
var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync();
|
||||||
|
var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync();
|
||||||
|
|
||||||
ProcessSeriesMetadataUpdate(series, allPeople, allGenres, forceUpdate);
|
ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate);
|
||||||
|
|
||||||
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
|
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
|
||||||
MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F));
|
MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F));
|
||||||
@ -502,6 +535,8 @@ public class MetadataService : IMetadataService
|
|||||||
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(series.LibraryId, series.Id));
|
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(series.LibraryId, series.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await RemoveAbandonedMetadataKeys();
|
||||||
|
|
||||||
_logger.LogInformation("[MetadataService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds);
|
_logger.LogInformation("[MetadataService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,7 +87,7 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
}
|
}
|
||||||
|
|
||||||
RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local);
|
RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local);
|
||||||
|
RecurringJob.AddOrUpdate("cleanup-db", () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local);
|
||||||
}
|
}
|
||||||
|
|
||||||
#region StatsTasks
|
#region StatsTasks
|
||||||
|
@ -13,6 +13,7 @@ namespace API.Services.Tasks
|
|||||||
public interface ICleanupService
|
public interface ICleanupService
|
||||||
{
|
{
|
||||||
Task Cleanup();
|
Task Cleanup();
|
||||||
|
Task CleanupDbEntries();
|
||||||
void CleanupCacheDirectory();
|
void CleanupCacheDirectory();
|
||||||
Task DeleteSeriesCoverImages();
|
Task DeleteSeriesCoverImages();
|
||||||
Task DeleteChapterCoverImages();
|
Task DeleteChapterCoverImages();
|
||||||
@ -66,6 +67,17 @@ namespace API.Services.Tasks
|
|||||||
_logger.LogInformation("Cleanup finished");
|
_logger.LogInformation("Cleanup finished");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cleans up abandon rows in the DB
|
||||||
|
/// </summary>
|
||||||
|
public async Task CleanupDbEntries()
|
||||||
|
{
|
||||||
|
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
|
||||||
|
await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated();
|
||||||
|
await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated();
|
||||||
|
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SendProgress(float progress)
|
private async Task SendProgress(float progress)
|
||||||
{
|
{
|
||||||
await _messageHub.Clients.All.SendAsync(SignalREvents.CleanupProgress,
|
await _messageHub.Clients.All.SendAsync(SignalREvents.CleanupProgress,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { MangaFile } from './manga-file';
|
import { MangaFile } from './manga-file';
|
||||||
import { Person } from './person';
|
import { Person } from './person';
|
||||||
|
import { Tag } from './tag';
|
||||||
|
|
||||||
export interface Chapter {
|
export interface Chapter {
|
||||||
id: number;
|
id: number;
|
||||||
@ -31,4 +32,5 @@ export interface Chapter {
|
|||||||
coverArtist: Array<Person>;
|
coverArtist: Array<Person>;
|
||||||
editor: Array<Person>;
|
editor: Array<Person>;
|
||||||
publisher: Array<Person>;
|
publisher: Array<Person>;
|
||||||
|
tags: Array<Tag>;
|
||||||
}
|
}
|
||||||
|
6
UI/Web/src/app/_models/metadata/age-rating-dto.ts
Normal file
6
UI/Web/src/app/_models/metadata/age-rating-dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { AgeRating } from "./age-rating";
|
||||||
|
|
||||||
|
export interface AgeRatingDto {
|
||||||
|
value: AgeRating;
|
||||||
|
title: string;
|
||||||
|
}
|
@ -9,7 +9,8 @@ export enum PersonRole {
|
|||||||
CoverArtist = 8,
|
CoverArtist = 8,
|
||||||
Editor = 9,
|
Editor = 9,
|
||||||
Publisher = 10,
|
Publisher = 10,
|
||||||
Character = 11
|
Character = 11,
|
||||||
|
Translator = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Person {
|
export interface Person {
|
||||||
|
@ -20,8 +20,24 @@ export interface SeriesFilter {
|
|||||||
editor: Array<number>;
|
editor: Array<number>;
|
||||||
publisher: Array<number>;
|
publisher: Array<number>;
|
||||||
character: Array<number>;
|
character: Array<number>;
|
||||||
|
translators: Array<number>;
|
||||||
collectionTags: Array<number>;
|
collectionTags: Array<number>;
|
||||||
rating: number;
|
rating: number;
|
||||||
|
ageRating: Array<number>;
|
||||||
|
sortOptions: SortOptions | null;
|
||||||
|
tags: Array<number>;
|
||||||
|
languages: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortOptions {
|
||||||
|
sortField: SortField;
|
||||||
|
isAscending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SortField {
|
||||||
|
SortName = 1,
|
||||||
|
Created = 2,
|
||||||
|
LastModified = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadStatus {
|
export interface ReadStatus {
|
||||||
|
@ -2,12 +2,14 @@ import { CollectionTag } from "./collection-tag";
|
|||||||
import { Genre } from "./genre";
|
import { Genre } from "./genre";
|
||||||
import { AgeRating } from "./metadata/age-rating";
|
import { AgeRating } from "./metadata/age-rating";
|
||||||
import { Person } from "./person";
|
import { Person } from "./person";
|
||||||
|
import { Tag } from "./tag";
|
||||||
|
|
||||||
export interface SeriesMetadata {
|
export interface SeriesMetadata {
|
||||||
publisher: string;
|
publisher: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
genres: Array<Genre>;
|
genres: Array<Genre>;
|
||||||
tags: Array<CollectionTag>;
|
tags: Array<Tag>;
|
||||||
|
collectionTags: Array<CollectionTag>;
|
||||||
writers: Array<Person>;
|
writers: Array<Person>;
|
||||||
artists: Array<Person>;
|
artists: Array<Person>;
|
||||||
publishers: Array<Person>;
|
publishers: Array<Person>;
|
||||||
@ -17,7 +19,9 @@ export interface SeriesMetadata {
|
|||||||
colorists: Array<Person>;
|
colorists: Array<Person>;
|
||||||
letterers: Array<Person>;
|
letterers: Array<Person>;
|
||||||
editors: Array<Person>;
|
editors: Array<Person>;
|
||||||
|
translators: Array<Person>;
|
||||||
ageRating: AgeRating;
|
ageRating: AgeRating;
|
||||||
releaseYear: number;
|
releaseYear: number;
|
||||||
|
language: string;
|
||||||
seriesId: number;
|
seriesId: number;
|
||||||
}
|
}
|
@ -7,7 +7,6 @@ export interface Series {
|
|||||||
originalName: string; // This is not shown to user
|
originalName: string; // This is not shown to user
|
||||||
localizedName: string;
|
localizedName: string;
|
||||||
sortName: string;
|
sortName: string;
|
||||||
//summary: string;
|
|
||||||
coverImageLocked: boolean;
|
coverImageLocked: boolean;
|
||||||
volumes: Volume[];
|
volumes: Volume[];
|
||||||
pages: number; // Total pages in series
|
pages: number; // Total pages in series
|
||||||
|
4
UI/Web/src/app/_models/tag.ts
Normal file
4
UI/Web/src/app/_models/tag.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface Tag {
|
||||||
|
id: number,
|
||||||
|
title: string;
|
||||||
|
}
|
@ -6,7 +6,10 @@ import { environment } from 'src/environments/environment';
|
|||||||
import { ChapterMetadata } from '../_models/chapter-metadata';
|
import { ChapterMetadata } from '../_models/chapter-metadata';
|
||||||
import { Genre } from '../_models/genre';
|
import { Genre } from '../_models/genre';
|
||||||
import { AgeRating } from '../_models/metadata/age-rating';
|
import { AgeRating } from '../_models/metadata/age-rating';
|
||||||
|
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
|
||||||
|
import { Language } from '../_models/metadata/language';
|
||||||
import { Person } from '../_models/person';
|
import { Person } from '../_models/person';
|
||||||
|
import { Tag } from '../_models/tag';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@ -19,29 +22,57 @@ export class MetadataService {
|
|||||||
|
|
||||||
constructor(private httpClient: HttpClient) { }
|
constructor(private httpClient: HttpClient) { }
|
||||||
|
|
||||||
// getChapterMetadata(chapterId: number) {
|
|
||||||
// return this.httpClient.get<ChapterMetadata>(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId);
|
|
||||||
// }
|
|
||||||
|
|
||||||
getAgeRating(ageRating: AgeRating) {
|
getAgeRating(ageRating: AgeRating) {
|
||||||
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
|
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
|
||||||
return of(this.ageRatingTypes[ageRating]);
|
return of(this.ageRatingTypes[ageRating]);
|
||||||
}
|
}
|
||||||
return this.httpClient.get<string>(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, {responseType: 'text' as 'json'}).pipe(map(l => {
|
return this.httpClient.get<string>(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, {responseType: 'text' as 'json'}).pipe(map(ratingString => {
|
||||||
if (this.ageRatingTypes === undefined) {
|
if (this.ageRatingTypes === undefined) {
|
||||||
this.ageRatingTypes = {};
|
this.ageRatingTypes = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ageRatingTypes[ageRating] = l;
|
this.ageRatingTypes[ageRating] = ratingString;
|
||||||
return this.ageRatingTypes[ageRating];
|
return this.ageRatingTypes[ageRating];
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllGenres() {
|
getAllAgeRatings(libraries?: Array<number>) {
|
||||||
return this.httpClient.get<Genre[]>(this.baseUrl + 'metadata/genres');
|
let method = 'metadata/age-ratings'
|
||||||
|
if (libraries != undefined && libraries.length > 0) {
|
||||||
|
method += '?libraryIds=' + libraries.join(',');
|
||||||
|
}
|
||||||
|
return this.httpClient.get<Array<AgeRatingDto>>(this.baseUrl + method);;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllPeople() {
|
getAllTags(libraries?: Array<number>) {
|
||||||
return this.httpClient.get<Person[]>(this.baseUrl + 'metadata/people');
|
let method = 'metadata/tags'
|
||||||
|
if (libraries != undefined && libraries.length > 0) {
|
||||||
|
method += '?libraryIds=' + libraries.join(',');
|
||||||
|
}
|
||||||
|
return this.httpClient.get<Array<Tag>>(this.baseUrl + method);;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllGenres(libraries?: Array<number>) {
|
||||||
|
let method = 'metadata/genres'
|
||||||
|
if (libraries != undefined && libraries.length > 0) {
|
||||||
|
method += '?libraryIds=' + libraries.join(',');
|
||||||
|
}
|
||||||
|
return this.httpClient.get<Genre[]>(this.baseUrl + method);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllLanguages(libraries?: Array<number>) {
|
||||||
|
let method = 'metadata/languages'
|
||||||
|
if (libraries != undefined && libraries.length > 0) {
|
||||||
|
method += '?libraryIds=' + libraries.join(',');
|
||||||
|
}
|
||||||
|
return this.httpClient.get<Language[]>(this.baseUrl + method);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllPeople(libraries?: Array<number>) {
|
||||||
|
let method = 'metadata/people'
|
||||||
|
if (libraries != undefined && libraries.length > 0) {
|
||||||
|
method += '?libraryIds=' + libraries.join(',');
|
||||||
|
}
|
||||||
|
return this.httpClient.get<Person[]>(this.baseUrl + method);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -39,6 +39,18 @@ export class SeriesService {
|
|||||||
return paginatedVariable;
|
return paginatedVariable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
|
||||||
|
let params = new HttpParams();
|
||||||
|
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||||
|
const data = this.createSeriesFilter(filter);
|
||||||
|
|
||||||
|
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe(
|
||||||
|
map((response: any) => {
|
||||||
|
return this._cachePaginatedResults(response, this.paginatedResults);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
|
getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
|
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||||
@ -137,7 +149,7 @@ export class SeriesService {
|
|||||||
|
|
||||||
getMetadata(seriesId: number) {
|
getMetadata(seriesId: number) {
|
||||||
return this.httpClient.get<SeriesMetadata>(this.baseUrl + 'series/metadata?seriesId=' + seriesId).pipe(map(items => {
|
return this.httpClient.get<SeriesMetadata>(this.baseUrl + 'series/metadata?seriesId=' + seriesId).pipe(map(items => {
|
||||||
items?.tags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id));
|
items?.collectionTags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id));
|
||||||
return items;
|
return items;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@ -189,13 +201,18 @@ export class SeriesService {
|
|||||||
editor: [],
|
editor: [],
|
||||||
publisher: [],
|
publisher: [],
|
||||||
character: [],
|
character: [],
|
||||||
|
translators: [],
|
||||||
collectionTags: [],
|
collectionTags: [],
|
||||||
rating: 0,
|
rating: 0,
|
||||||
readStatus: {
|
readStatus: {
|
||||||
read: true,
|
read: true,
|
||||||
inProgress: true,
|
inProgress: true,
|
||||||
notRead: true
|
notRead: true
|
||||||
}
|
},
|
||||||
|
sortOptions: null,
|
||||||
|
ageRating: [],
|
||||||
|
tags: [],
|
||||||
|
languages: []
|
||||||
};
|
};
|
||||||
|
|
||||||
if (filter === undefined) return data;
|
if (filter === undefined) return data;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
|
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
|
||||||
<p class="text-warning pt-2">Port, Base Url, and Logging Level require a manual restart of Kavita to take effect.</p>
|
<p class="text-warning pt-2">Port and Logging Level require a manual restart of Kavita to take effect.</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="settings-cachedir">Cache Directory</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheDirectoryTooltip" role="button" tabindex="0"></i>
|
<label for="settings-cachedir">Cache Directory</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheDirectoryTooltip" role="button" tabindex="0"></i>
|
||||||
<ng-template #cacheDirectoryTooltip>Where the server place temporary files when reading. This will be cleaned up on a regular basis.</ng-template>
|
<ng-template #cacheDirectoryTooltip>Where the server place temporary files when reading. This will be cleaned up on a regular basis.</ng-template>
|
||||||
|
14
UI/Web/src/app/all-series/all-series.component.html
Normal file
14
UI/Web/src/app/all-series/all-series.component.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||||
|
<app-card-detail-layout header="All Series"
|
||||||
|
[isLoading]="loadingSeries"
|
||||||
|
[items]="series"
|
||||||
|
[actions]="actions"
|
||||||
|
[pagination]="pagination"
|
||||||
|
[filterSettings]="filterSettings"
|
||||||
|
(applyFilter)="updateFilter($event)"
|
||||||
|
(pageChange)="onPageChange($event)"
|
||||||
|
>
|
||||||
|
<ng-template #cardItem let-item let-position="idx">
|
||||||
|
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()" (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||||
|
</ng-template>
|
||||||
|
</app-card-detail-layout>
|
0
UI/Web/src/app/all-series/all-series.component.scss
Normal file
0
UI/Web/src/app/all-series/all-series.component.scss
Normal file
145
UI/Web/src/app/all-series/all-series.component.ts
Normal file
145
UI/Web/src/app/all-series/all-series.component.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { Title } from '@angular/platform-browser';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { take, debounceTime, takeUntil } from 'rxjs/operators';
|
||||||
|
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||||
|
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||||
|
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||||
|
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||||
|
import { Library } from '../_models/library';
|
||||||
|
import { Pagination } from '../_models/pagination';
|
||||||
|
import { Series } from '../_models/series';
|
||||||
|
import { SeriesFilter } from '../_models/series-filter';
|
||||||
|
import { ActionItem, Action } from '../_services/action-factory.service';
|
||||||
|
import { ActionService } from '../_services/action.service';
|
||||||
|
import { MessageHubService } from '../_services/message-hub.service';
|
||||||
|
import { SeriesService } from '../_services/series.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-all-series',
|
||||||
|
templateUrl: './all-series.component.html',
|
||||||
|
styleUrls: ['./all-series.component.scss']
|
||||||
|
})
|
||||||
|
export class AllSeriesComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
series: Series[] = [];
|
||||||
|
loadingSeries = false;
|
||||||
|
pagination!: Pagination;
|
||||||
|
actions: ActionItem<Library>[] = [];
|
||||||
|
filter: SeriesFilter | undefined = undefined;
|
||||||
|
onDestroy: Subject<void> = new Subject<void>();
|
||||||
|
filterSettings: FilterSettings = new FilterSettings();
|
||||||
|
|
||||||
|
bulkActionCallback = (action: Action, data: any) => {
|
||||||
|
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||||
|
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case Action.AddToReadingList:
|
||||||
|
this.actionService.addMultipleSeriesToReadingList(selectedSeries, () => {
|
||||||
|
this.bulkSelectionService.deselectAll();
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case Action.AddToCollection:
|
||||||
|
this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, () => {
|
||||||
|
this.bulkSelectionService.deselectAll();
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case Action.MarkAsRead:
|
||||||
|
this.actionService.markMultipleSeriesAsRead(selectedSeries, () => {
|
||||||
|
this.loadPage();
|
||||||
|
this.bulkSelectionService.deselectAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
case Action.MarkAsUnread:
|
||||||
|
this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => {
|
||||||
|
this.loadPage();
|
||||||
|
this.bulkSelectionService.deselectAll();
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case Action.Delete:
|
||||||
|
this.actionService.deleteMultipleSeries(selectedSeries, () => {
|
||||||
|
this.loadPage();
|
||||||
|
this.bulkSelectionService.deselectAll();
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private router: Router, private seriesService: SeriesService,
|
||||||
|
private titleService: Title, private actionService: ActionService,
|
||||||
|
public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService) {
|
||||||
|
|
||||||
|
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||||
|
|
||||||
|
this.titleService.setTitle('Kavita - All Series');
|
||||||
|
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||||
|
|
||||||
|
this.loadPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.hubService.seriesAdded.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: SeriesAddedEvent) => {
|
||||||
|
this.loadPage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.onDestroy.next();
|
||||||
|
this.onDestroy.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.shift', ['$event'])
|
||||||
|
handleKeypress(event: KeyboardEvent) {
|
||||||
|
if (event.key === KEY_CODES.SHIFT) {
|
||||||
|
this.bulkSelectionService.isShiftDown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keyup.shift', ['$event'])
|
||||||
|
handleKeyUp(event: KeyboardEvent) {
|
||||||
|
if (event.key === KEY_CODES.SHIFT) {
|
||||||
|
this.bulkSelectionService.isShiftDown = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFilter(data: SeriesFilter) {
|
||||||
|
this.filter = data;
|
||||||
|
if (this.pagination !== undefined && this.pagination !== null) {
|
||||||
|
this.pagination.currentPage = 1;
|
||||||
|
this.onPageChange(this.pagination);
|
||||||
|
} else {
|
||||||
|
this.loadPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPage() {
|
||||||
|
const page = this.getPage();
|
||||||
|
if (page != null) {
|
||||||
|
this.pagination.currentPage = parseInt(page, 10);
|
||||||
|
}
|
||||||
|
this.loadingSeries = true;
|
||||||
|
|
||||||
|
this.seriesService.getAllSeries(this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
||||||
|
this.series = series.result;
|
||||||
|
this.pagination = series.pagination;
|
||||||
|
this.loadingSeries = false;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(pagination: Pagination) {
|
||||||
|
window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage);
|
||||||
|
this.loadPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.originalName}_${item.localizedName}_${item.pagesRead}`;
|
||||||
|
|
||||||
|
getPage() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
return urlParams.get('page');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -9,6 +9,7 @@ import { AuthGuard } from './_guards/auth.guard';
|
|||||||
import { LibraryAccessGuard } from './_guards/library-access.guard';
|
import { LibraryAccessGuard } from './_guards/library-access.guard';
|
||||||
import { OnDeckComponent } from './on-deck/on-deck.component';
|
import { OnDeckComponent } from './on-deck/on-deck.component';
|
||||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||||
|
import { AllSeriesComponent } from './all-series/all-series.component';
|
||||||
|
|
||||||
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
|
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
|
||||||
|
|
||||||
@ -55,6 +56,8 @@ const routes: Routes = [
|
|||||||
{path: 'library', component: DashboardComponent},
|
{path: 'library', component: DashboardComponent},
|
||||||
{path: 'recently-added', component: RecentlyAddedComponent},
|
{path: 'recently-added', component: RecentlyAddedComponent},
|
||||||
{path: 'on-deck', component: OnDeckComponent},
|
{path: 'on-deck', component: OnDeckComponent},
|
||||||
|
{path: 'all-series', component: AllSeriesComponent},
|
||||||
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{path: 'login', component: UserLoginComponent},
|
{path: 'login', component: UserLoginComponent},
|
||||||
|
@ -34,6 +34,7 @@ import { ConfigData } from './_models/config-data';
|
|||||||
import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component';
|
import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component';
|
||||||
import { PersonRolePipe } from './person-role.pipe';
|
import { PersonRolePipe } from './person-role.pipe';
|
||||||
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
|
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
|
||||||
|
import { AllSeriesComponent } from './all-series/all-series.component';
|
||||||
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@ -52,6 +53,7 @@ import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-m
|
|||||||
NavEventsToggleComponent,
|
NavEventsToggleComponent,
|
||||||
PersonRolePipe,
|
PersonRolePipe,
|
||||||
SeriesMetadataDetailComponent,
|
SeriesMetadataDetailComponent,
|
||||||
|
AllSeriesComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
|
@ -86,8 +86,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
this.seriesService.getMetadata(this.series.id).subscribe(metadata => {
|
this.seriesService.getMetadata(this.series.id).subscribe(metadata => {
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
this.metadata = metadata;
|
this.metadata = metadata;
|
||||||
this.settings.savedData = metadata.tags;
|
this.settings.savedData = metadata.collectionTags;
|
||||||
this.tags = metadata.tags;
|
this.tags = metadata.collectionTags;
|
||||||
this.editSeriesForm.get('summary')?.setValue(this.metadata.summary);
|
this.editSeriesForm.get('summary')?.setValue(this.metadata.summary);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -40,7 +40,7 @@
|
|||||||
<ng-template #filterSection>
|
<ng-template #filterSection>
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="row no-gutters">
|
<div class="row no-gutters">
|
||||||
<div class="col-md-3" *ngIf="!filterSettings.formatDisabled">
|
<div class="col-md-2 mr-3" *ngIf="!filterSettings.formatDisabled">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="format">Format</label>
|
<label for="format">Format</label>
|
||||||
<app-typeahead (selectedData)="updateFormatFilters($event)" [settings]="formatSettings" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updateFormatFilters($event)" [settings]="formatSettings" [reset]="resetTypeaheads">
|
||||||
@ -54,7 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3"*ngIf="!filterSettings.libraryDisabled">
|
<div class="col-md-2 mr-3"*ngIf="!filterSettings.libraryDisabled">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="libraries">Libraries</label>
|
<label for="libraries">Libraries</label>
|
||||||
<app-typeahead (selectedData)="updateLibraryFilters($event)" [settings]="librarySettings" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updateLibraryFilters($event)" [settings]="librarySettings" [reset]="resetTypeaheads">
|
||||||
@ -68,7 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3" *ngIf="!filterSettings.collectionDisabled">
|
<div class="col-md-2 mr-3" *ngIf="!filterSettings.collectionDisabled">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="collections">Collections</label>
|
<label for="collections">Collections</label>
|
||||||
<app-typeahead (selectedData)="updateCollectionFilters($event)" [settings]="collectionSettings" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updateCollectionFilters($event)" [settings]="collectionSettings" [reset]="resetTypeaheads">
|
||||||
@ -82,7 +82,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3" *ngIf="!filterSettings.genresDisabled">
|
<div class="col-md-2 mr-3" *ngIf="!filterSettings.genresDisabled">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="genres">Genres</label>
|
<label for="genres">Genres</label>
|
||||||
<app-typeahead (selectedData)="updateGenreFilters($event)" [settings]="genreSettings" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updateGenreFilters($event)" [settings]="genreSettings" [reset]="resetTypeaheads">
|
||||||
@ -95,10 +95,24 @@
|
|||||||
</app-typeahead>
|
</app-typeahead>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2 mr-3" *ngIf="!filterSettings.tagsDisabled">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tags">Tags</label>
|
||||||
|
<app-typeahead (selectedData)="updateTagFilters($event)" [settings]="tagsSettings" [reset]="resetTypeaheads">
|
||||||
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
|
{{item.title}}
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
|
{{item.title}}
|
||||||
|
</ng-template>
|
||||||
|
</app-typeahead>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row no-gutters">
|
<div class="row no-gutters">
|
||||||
<!-- The People row -->
|
<!-- The People row -->
|
||||||
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.CoverArtist)">
|
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.CoverArtist)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="cover-artist">(Cover) Artists</label>
|
<label for="cover-artist">(Cover) Artists</label>
|
||||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)" [reset]="resetTypeaheads">
|
||||||
@ -112,7 +126,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Writer)">
|
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Writer)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="writers">Writers</label>
|
<label for="writers">Writers</label>
|
||||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Writer)" [settings]="getPersonsSettings(PersonRole.Writer)" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Writer)" [settings]="getPersonsSettings(PersonRole.Writer)" [reset]="resetTypeaheads">
|
||||||
@ -126,7 +140,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Publisher)">
|
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Publisher)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="publisher">Publisher</label>
|
<label for="publisher">Publisher</label>
|
||||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)" [reset]="resetTypeaheads">
|
||||||
@ -140,9 +154,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Penciller)">
|
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Penciller)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="Penciller">Penciller</label>
|
<label for="penciller">Penciller</label>
|
||||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Penciller)" [settings]="getPersonsSettings(PersonRole.Penciller)" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Penciller)" [settings]="getPersonsSettings(PersonRole.Penciller)" [reset]="resetTypeaheads">
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
{{item.title}}
|
{{item.title}}
|
||||||
@ -154,7 +168,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Letterer)">
|
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Letterer)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="letterer">Letterer</label>
|
<label for="letterer">Letterer</label>
|
||||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Letterer)" [settings]="getPersonsSettings(PersonRole.Letterer)" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Letterer)" [settings]="getPersonsSettings(PersonRole.Letterer)" [reset]="resetTypeaheads">
|
||||||
@ -168,7 +182,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Inker)">
|
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Inker)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="inker">Inker</label>
|
<label for="inker">Inker</label>
|
||||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Inker)" [settings]="getPersonsSettings(PersonRole.Inker)" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Inker)" [settings]="getPersonsSettings(PersonRole.Inker)" [reset]="resetTypeaheads">
|
||||||
@ -182,7 +196,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Editor)">
|
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Editor)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editor">Editor</label>
|
<label for="editor">Editor</label>
|
||||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Editor)" [settings]="getPersonsSettings(PersonRole.Editor)" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Editor)" [settings]="getPersonsSettings(PersonRole.Editor)" [reset]="resetTypeaheads">
|
||||||
@ -196,7 +210,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Colorist)">
|
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Colorist)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="colorist">Colorist</label>
|
<label for="colorist">Colorist</label>
|
||||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Colorist)" [settings]="getPersonsSettings(PersonRole.Colorist)" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Colorist)" [settings]="getPersonsSettings(PersonRole.Colorist)" [reset]="resetTypeaheads">
|
||||||
@ -210,7 +224,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Character)">
|
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Character)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="character">Character</label>
|
<label for="character">Character</label>
|
||||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Character)" [settings]="getPersonsSettings(PersonRole.Character)" [reset]="resetTypeaheads">
|
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Character)" [settings]="getPersonsSettings(PersonRole.Character)" [reset]="resetTypeaheads">
|
||||||
@ -223,11 +237,23 @@
|
|||||||
</app-typeahead>
|
</app-typeahead>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Translator)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="translators">Translators</label>
|
||||||
|
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Translator)" [settings]="getPersonsSettings(PersonRole.Translator)" [reset]="resetTypeaheads">
|
||||||
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
|
{{item.title}}
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
|
{{item.title}}
|
||||||
|
</ng-template>
|
||||||
|
</app-typeahead>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row no-gutters">
|
<div class="row no-gutters">
|
||||||
<!-- Rating/Review/Progress -->
|
<div class="col-md-2 mr-3" *ngIf="!filterSettings.readProgressDisabled">
|
||||||
<div class="col" *ngIf="!filterSettings.readProgressDisabled">
|
|
||||||
<!-- Not sure how to do this on the backend, might have to be a UI control -->
|
|
||||||
<label>Read Progress</label>
|
<label>Read Progress</label>
|
||||||
<form [formGroup]="readProgressGroup" class="ml-2">
|
<form [formGroup]="readProgressGroup" class="ml-2">
|
||||||
<div class="form-check form-check-inline">
|
<div class="form-check form-check-inline">
|
||||||
@ -245,7 +271,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col" *ngIf="!filterSettings.ratingDisabled">
|
<div class="col-md-2 mr-3" *ngIf="!filterSettings.ratingDisabled">
|
||||||
<label for="ratings">Rating</label>
|
<label for="ratings">Rating</label>
|
||||||
<form class="form-inline ml-2">
|
<form class="form-inline ml-2">
|
||||||
<ngb-rating class="rating-star" [(rate)]="filter.rating" (rateChange)="updateRating($event)" [resettable]="true">
|
<ngb-rating class="rating-star" [(rate)]="filter.rating" (rateChange)="updateRating($event)" [resettable]="true">
|
||||||
@ -255,15 +281,59 @@
|
|||||||
</ngb-rating>
|
</ngb-rating>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col">
|
<div class="col-md-2 mr-3" *ngIf="!filterSettings.ageRatingDisabled">
|
||||||
<button class="btn btn-secondary mr-2" (click)="clear()">Clear</button>
|
<label for="age-rating">Age Rating</label>
|
||||||
<button class="btn btn-primary" (click)="apply()">Apply</button>
|
<app-typeahead (selectedData)="updateAgeRating($event)" [settings]="ageRatingSettings" [reset]="resetTypeaheads">
|
||||||
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
|
{{item.title}}
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
|
{{item.title}}
|
||||||
|
</ng-template>
|
||||||
|
</app-typeahead>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2 mr-3" *ngIf="!filterSettings.languageDisabled">
|
||||||
|
<label for="languages">Language</label>
|
||||||
|
<app-typeahead (selectedData)="updateLanguageRating($event)" [settings]="languageSettings" [reset]="resetTypeaheads">
|
||||||
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
|
{{item.title}}
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
|
{{item.title}}
|
||||||
|
</ng-template>
|
||||||
|
</app-typeahead>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2 mr-3" *ngIf="!filterSettings.sortDisabled">
|
||||||
|
<form [formGroup]="sortGroup">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sort-options">Sort By</label>
|
||||||
|
<button class="btn btn-sm btn-secondary-outline" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0px;">
|
||||||
|
<i class="fa fa-arrow-down" title="Ascending" *ngIf="isAscendingSort; else descSort"></i>
|
||||||
|
<ng-template #descSort>
|
||||||
|
<i class="fa fa-arrow-up" title="Descending"></i>
|
||||||
|
</ng-template>
|
||||||
|
</button>
|
||||||
|
<select id="sort-options" class="form-control" formControlName="sortField" style="height: 38px;">
|
||||||
|
<option [value]="SortField.SortName">Sort Name</option>
|
||||||
|
<option [value]="SortField.Created">Created</option>
|
||||||
|
<option [value]="SortField.LastModified">Last Modified</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row no-gutters">
|
<div class="row no-gutters">
|
||||||
<!-- Sort by functionalities -->
|
<div class="col">
|
||||||
|
<!-- TODO: Make this float right -->
|
||||||
|
<button class="btn btn-secondary mr-2" (click)="clear()">Clear</button>
|
||||||
|
<button class="btn btn-primary" (click)="apply()">Apply</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -1,16 +1,20 @@
|
|||||||
import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output, TemplateRef } from '@angular/core';
|
import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output, TemplateRef } from '@angular/core';
|
||||||
import { FormControl, FormGroup } from '@angular/forms';
|
import { FormControl, FormGroup } from '@angular/forms';
|
||||||
import { of, ReplaySubject, Subject } from 'rxjs';
|
import { Observable, of, ReplaySubject, Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { map, takeUntil } from 'rxjs/operators';
|
||||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||||
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
|
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
|
||||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||||
import { Genre } from 'src/app/_models/genre';
|
import { Genre } from 'src/app/_models/genre';
|
||||||
import { Library } from 'src/app/_models/library';
|
import { Library } from 'src/app/_models/library';
|
||||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||||
|
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||||
|
import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto';
|
||||||
|
import { Language } from 'src/app/_models/metadata/language';
|
||||||
import { Pagination } from 'src/app/_models/pagination';
|
import { Pagination } from 'src/app/_models/pagination';
|
||||||
import { Person, PersonRole } from 'src/app/_models/person';
|
import { Person, PersonRole } from 'src/app/_models/person';
|
||||||
import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter';
|
import { FilterItem, mangaFormatFilters, SeriesFilter, SortField } from 'src/app/_models/series-filter';
|
||||||
|
import { Tag } from 'src/app/_models/tag';
|
||||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||||
import { LibraryService } from 'src/app/_services/library.service';
|
import { LibraryService } from 'src/app/_services/library.service';
|
||||||
@ -29,6 +33,12 @@ export class FilterSettings {
|
|||||||
peopleDisabled = false;
|
peopleDisabled = false;
|
||||||
readProgressDisabled = false;
|
readProgressDisabled = false;
|
||||||
ratingDisabled = false;
|
ratingDisabled = false;
|
||||||
|
presetLibraryId = 0;
|
||||||
|
presetCollectionId = 0;
|
||||||
|
sortDisabled = false;
|
||||||
|
ageRatingDisabled = false;
|
||||||
|
tagsDisabled = false;
|
||||||
|
languageDisabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -59,6 +69,9 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
librarySettings: TypeaheadSettings<FilterItem<Library>> = new TypeaheadSettings();
|
librarySettings: TypeaheadSettings<FilterItem<Library>> = new TypeaheadSettings();
|
||||||
genreSettings: TypeaheadSettings<FilterItem<Genre>> = new TypeaheadSettings();
|
genreSettings: TypeaheadSettings<FilterItem<Genre>> = new TypeaheadSettings();
|
||||||
collectionSettings: TypeaheadSettings<FilterItem<CollectionTag>> = new TypeaheadSettings();
|
collectionSettings: TypeaheadSettings<FilterItem<CollectionTag>> = new TypeaheadSettings();
|
||||||
|
ageRatingSettings: TypeaheadSettings<FilterItem<AgeRatingDto>> = new TypeaheadSettings();
|
||||||
|
tagsSettings: TypeaheadSettings<FilterItem<Tag>> = new TypeaheadSettings();
|
||||||
|
languageSettings: TypeaheadSettings<FilterItem<Language>> = new TypeaheadSettings();
|
||||||
peopleSettings: {[PersonRole: string]: TypeaheadSettings<FilterItem<Person>>} = {};
|
peopleSettings: {[PersonRole: string]: TypeaheadSettings<FilterItem<Person>>} = {};
|
||||||
resetTypeaheads: Subject<boolean> = new ReplaySubject(1);
|
resetTypeaheads: Subject<boolean> = new ReplaySubject(1);
|
||||||
|
|
||||||
@ -74,6 +87,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
collectionTags: Array<FilterItem<CollectionTag>> = [];
|
collectionTags: Array<FilterItem<CollectionTag>> = [];
|
||||||
|
|
||||||
readProgressGroup!: FormGroup;
|
readProgressGroup!: FormGroup;
|
||||||
|
sortGroup!: FormGroup;
|
||||||
|
isAscendingSort: boolean = true;
|
||||||
|
|
||||||
updateApplied: number = 0;
|
updateApplied: number = 0;
|
||||||
|
|
||||||
@ -83,6 +98,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
return PersonRole;
|
return PersonRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get SortField(): typeof SortField {
|
||||||
|
return SortField;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(private libraryService: LibraryService, private metadataService: MetadataService, private seriesService: SeriesService,
|
constructor(private libraryService: LibraryService, private metadataService: MetadataService, private seriesService: SeriesService,
|
||||||
private utilityService: UtilityService, private collectionTagService: CollectionTagService) {
|
private utilityService: UtilityService, private collectionTagService: CollectionTagService) {
|
||||||
this.filter = this.seriesService.createSeriesFilter();
|
this.filter = this.seriesService.createSeriesFilter();
|
||||||
@ -92,10 +111,39 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
inProgress: new FormControl(this.filter.readStatus.inProgress, []),
|
inProgress: new FormControl(this.filter.readStatus.inProgress, []),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.sortGroup = new FormGroup({
|
||||||
|
sortField: new FormControl(this.filter.sortOptions?.sortField || SortField.SortName, []),
|
||||||
|
});
|
||||||
|
|
||||||
this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
|
this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
|
||||||
this.filter.readStatus.read = this.readProgressGroup.get('read')?.value;
|
this.filter.readStatus.read = this.readProgressGroup.get('read')?.value;
|
||||||
this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value;
|
this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value;
|
||||||
this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value;
|
this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value;
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
sum += (this.filter.readStatus.read ? 1 : 0);
|
||||||
|
sum += (this.filter.readStatus.inProgress ? 1 : 0);
|
||||||
|
sum += (this.filter.readStatus.notRead ? 1 : 0);
|
||||||
|
|
||||||
|
if (sum === 1) {
|
||||||
|
if (this.filter.readStatus.read) this.readProgressGroup.get('read')?.disable({ emitEvent: false });
|
||||||
|
if (this.filter.readStatus.notRead) this.readProgressGroup.get('notRead')?.disable({ emitEvent: false });
|
||||||
|
if (this.filter.readStatus.inProgress) this.readProgressGroup.get('inProgress')?.disable({ emitEvent: false });
|
||||||
|
} else {
|
||||||
|
this.readProgressGroup.get('read')?.enable({ emitEvent: false });
|
||||||
|
this.readProgressGroup.get('notRead')?.enable({ emitEvent: false });
|
||||||
|
this.readProgressGroup.get('inProgress')?.enable({ emitEvent: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sortGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
|
||||||
|
if (this.filter.sortOptions == null) {
|
||||||
|
this.filter.sortOptions = {
|
||||||
|
isAscending: this.isAscendingSort,
|
||||||
|
sortField: parseInt(this.sortGroup.get('sortField')?.value, 10)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.filter.sortOptions.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,18 +155,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
this.filterSettings = new FilterSettings();
|
this.filterSettings = new FilterSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setupGenreTypeahead();
|
||||||
this.metadataService.getAllGenres().subscribe(genres => {
|
|
||||||
this.genres = genres.map(genre => {
|
|
||||||
return {
|
|
||||||
title: genre.title,
|
|
||||||
value: genre,
|
|
||||||
selected: false,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.setupGenreTypeahead();
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
this.libraryService.getLibrariesForMember().subscribe(libs => {
|
this.libraryService.getLibrariesForMember().subscribe(libs => {
|
||||||
this.libraries = libs.map(lib => {
|
this.libraries = libs.map(lib => {
|
||||||
@ -131,27 +168,11 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
this.setupLibraryTypeahead();
|
this.setupLibraryTypeahead();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.metadataService.getAllPeople().subscribe(res => {
|
this.setupCollectionTagTypeahead();
|
||||||
this.persons = res.map(lib => {
|
this.setupPersonTypeahead();
|
||||||
return {
|
this.setupAgeRatingSettings();
|
||||||
title: lib.name,
|
this.setupTagSettings();
|
||||||
value: lib,
|
this.setupLanguageSettings();
|
||||||
selected: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.setupPersonTypeahead();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.collectionTagService.allTags().subscribe(tags => {
|
|
||||||
this.collectionTags = tags.map(lib => {
|
|
||||||
return {
|
|
||||||
title: lib.title,
|
|
||||||
value: lib,
|
|
||||||
selected: false,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.setupCollectionTagTypeahead();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
@ -171,7 +192,6 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
const f = filter.toLowerCase();
|
const f = filter.toLowerCase();
|
||||||
return options.filter(m => m.title.toLowerCase() === f);
|
return options.filter(m => m.title.toLowerCase() === f);
|
||||||
}
|
}
|
||||||
this.formatSettings.savedData = mangaFormatFilters;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupLibraryTypeahead() {
|
setupLibraryTypeahead() {
|
||||||
@ -187,6 +207,12 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
const f = filter.toLowerCase();
|
const f = filter.toLowerCase();
|
||||||
return options.filter(m => m.title.toLowerCase() === f);
|
return options.filter(m => m.title.toLowerCase() === f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.filterSettings.presetLibraryId > 0) {
|
||||||
|
this.librarySettings.savedData = this.libraries.filter(item => item.value.id === this.filterSettings.presetLibraryId);
|
||||||
|
this.filter.libraries = this.librarySettings.savedData.map(item => item.value.id);
|
||||||
|
this.resetTypeaheads.next(true); // For some reason library just doesn't update properly with savedData
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupGenreTypeahead() {
|
setupGenreTypeahead() {
|
||||||
@ -196,7 +222,15 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
this.genreSettings.unique = true;
|
this.genreSettings.unique = true;
|
||||||
this.genreSettings.addIfNonExisting = false;
|
this.genreSettings.addIfNonExisting = false;
|
||||||
this.genreSettings.fetchFn = (filter: string) => {
|
this.genreSettings.fetchFn = (filter: string) => {
|
||||||
return of (this.genres)
|
return this.metadataService.getAllGenres(this.filter.libraries).pipe(map(genres => {
|
||||||
|
return genres.map(genre => {
|
||||||
|
return {
|
||||||
|
title: genre.title,
|
||||||
|
value: genre,
|
||||||
|
selected: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
this.genreSettings.compareFn = (options: FilterItem<Genre>[], filter: string) => {
|
this.genreSettings.compareFn = (options: FilterItem<Genre>[], filter: string) => {
|
||||||
const f = filter.toLowerCase();
|
const f = filter.toLowerCase();
|
||||||
@ -204,6 +238,75 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupAgeRatingSettings() {
|
||||||
|
this.ageRatingSettings.minCharacters = 0;
|
||||||
|
this.ageRatingSettings.multiple = true;
|
||||||
|
this.ageRatingSettings.id = 'age-rating';
|
||||||
|
this.ageRatingSettings.unique = true;
|
||||||
|
this.ageRatingSettings.addIfNonExisting = false;
|
||||||
|
this.ageRatingSettings.fetchFn = (filter: string) => {
|
||||||
|
return this.metadataService.getAllAgeRatings(this.filter.libraries).pipe(map(ratings => {
|
||||||
|
return ratings.map(rating => {
|
||||||
|
return {
|
||||||
|
title: rating.title,
|
||||||
|
value: rating,
|
||||||
|
selected: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
this.ageRatingSettings.compareFn = (options: FilterItem<AgeRatingDto>[], filter: string) => {
|
||||||
|
const f = filter.toLowerCase();
|
||||||
|
return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupTagSettings() {
|
||||||
|
this.tagsSettings.minCharacters = 0;
|
||||||
|
this.tagsSettings.multiple = true;
|
||||||
|
this.tagsSettings.id = 'tags';
|
||||||
|
this.tagsSettings.unique = true;
|
||||||
|
this.tagsSettings.addIfNonExisting = false;
|
||||||
|
this.tagsSettings.fetchFn = (filter: string) => {
|
||||||
|
return this.metadataService.getAllTags(this.filter.libraries).pipe(map(tags => {
|
||||||
|
return tags.map(tag => {
|
||||||
|
return {
|
||||||
|
title: tag.title,
|
||||||
|
value: tag,
|
||||||
|
selected: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
this.tagsSettings.compareFn = (options: FilterItem<Tag>[], filter: string) => {
|
||||||
|
const f = filter.toLowerCase();
|
||||||
|
return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLanguageSettings() {
|
||||||
|
this.languageSettings.minCharacters = 0;
|
||||||
|
this.languageSettings.multiple = true;
|
||||||
|
this.languageSettings.id = 'languages';
|
||||||
|
this.languageSettings.unique = true;
|
||||||
|
this.languageSettings.addIfNonExisting = false;
|
||||||
|
this.languageSettings.fetchFn = (filter: string) => {
|
||||||
|
return this.metadataService.getAllLanguages(this.filter.libraries).pipe(map(tags => {
|
||||||
|
return tags.map(tag => {
|
||||||
|
return {
|
||||||
|
title: tag.title,
|
||||||
|
value: tag,
|
||||||
|
selected: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
this.languageSettings.compareFn = (options: FilterItem<Language>[], filter: string) => {
|
||||||
|
const f = filter.toLowerCase();
|
||||||
|
return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setupCollectionTagTypeahead() {
|
setupCollectionTagTypeahead() {
|
||||||
this.collectionSettings.minCharacters = 0;
|
this.collectionSettings.minCharacters = 0;
|
||||||
this.collectionSettings.multiple = true;
|
this.collectionSettings.multiple = true;
|
||||||
@ -211,12 +314,25 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
this.collectionSettings.unique = true;
|
this.collectionSettings.unique = true;
|
||||||
this.collectionSettings.addIfNonExisting = false;
|
this.collectionSettings.addIfNonExisting = false;
|
||||||
this.collectionSettings.fetchFn = (filter: string) => {
|
this.collectionSettings.fetchFn = (filter: string) => {
|
||||||
return of (this.collectionTags)
|
return this.collectionTagService.allTags().pipe(map(tags => {
|
||||||
|
return tags.map(lib => {
|
||||||
|
return {
|
||||||
|
title: lib.title,
|
||||||
|
value: lib,
|
||||||
|
selected: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
this.collectionSettings.compareFn = (options: FilterItem<CollectionTag>[], filter: string) => {
|
this.collectionSettings.compareFn = (options: FilterItem<CollectionTag>[], filter: string) => {
|
||||||
const f = filter.toLowerCase();
|
const f = filter.toLowerCase();
|
||||||
return options.filter(m => m.title.toLowerCase() === f);
|
return options.filter(m => m.title.toLowerCase() === f);
|
||||||
}
|
}
|
||||||
|
if (this.filterSettings.presetCollectionId > 0) {
|
||||||
|
this.collectionSettings.savedData = this.collectionTags.filter(item => item.value.id === this.filterSettings.presetCollectionId);
|
||||||
|
this.filter.collectionTags = this.collectionSettings.savedData.map(item => item.value.id);
|
||||||
|
this.resetTypeaheads.next(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupPersonTypeahead() {
|
setupPersonTypeahead() {
|
||||||
@ -224,58 +340,75 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
var personSettings = this.createBlankPersonSettings('writers');
|
var personSettings = this.createBlankPersonSettings('writers');
|
||||||
personSettings.fetchFn = (filter: string) => {
|
personSettings.fetchFn = (filter: string) => {
|
||||||
return of (this.persons.filter(p => p.value.role == PersonRole.Writer && this.utilityService.filter(p.value.name, filter)));
|
return this.fetchPeople(PersonRole.Writer, filter);
|
||||||
};
|
};
|
||||||
this.peopleSettings[PersonRole.Writer] = personSettings;
|
this.peopleSettings[PersonRole.Writer] = personSettings;
|
||||||
|
|
||||||
personSettings = this.createBlankPersonSettings('character');
|
personSettings = this.createBlankPersonSettings('character');
|
||||||
personSettings.fetchFn = (filter: string) => {
|
personSettings.fetchFn = (filter: string) => {
|
||||||
|
return this.fetchPeople(PersonRole.Character, filter);
|
||||||
return of (this.persons.filter(p => p.value.role == PersonRole.Character && this.utilityService.filter(p.title, filter)))
|
|
||||||
};
|
};
|
||||||
this.peopleSettings[PersonRole.Character] = personSettings;
|
this.peopleSettings[PersonRole.Character] = personSettings;
|
||||||
|
|
||||||
personSettings = this.createBlankPersonSettings('colorist');
|
personSettings = this.createBlankPersonSettings('colorist');
|
||||||
personSettings.fetchFn = (filter: string) => {
|
personSettings.fetchFn = (filter: string) => {
|
||||||
return of (this.persons.filter(p => p.value.role == PersonRole.Colorist && this.utilityService.filter(p.title, filter)))
|
return this.fetchPeople(PersonRole.Colorist, filter);
|
||||||
};
|
};
|
||||||
this.peopleSettings[PersonRole.Colorist] = personSettings;
|
this.peopleSettings[PersonRole.Colorist] = personSettings;
|
||||||
|
|
||||||
personSettings = this.createBlankPersonSettings('cover-artist');
|
personSettings = this.createBlankPersonSettings('cover-artist');
|
||||||
personSettings.fetchFn = (filter: string) => {
|
personSettings.fetchFn = (filter: string) => {
|
||||||
return of (this.persons.filter(p => p.value.role == PersonRole.CoverArtist && this.utilityService.filter(p.title, filter)))
|
return this.fetchPeople(PersonRole.CoverArtist, filter);
|
||||||
};
|
};
|
||||||
this.peopleSettings[PersonRole.CoverArtist] = personSettings;
|
this.peopleSettings[PersonRole.CoverArtist] = personSettings;
|
||||||
|
|
||||||
personSettings = this.createBlankPersonSettings('editor');
|
personSettings = this.createBlankPersonSettings('editor');
|
||||||
personSettings.fetchFn = (filter: string) => {
|
personSettings.fetchFn = (filter: string) => {
|
||||||
return of (this.persons.filter(p => p.value.role == PersonRole.Editor && this.utilityService.filter(p.title, filter)))
|
return this.fetchPeople(PersonRole.Editor, filter);
|
||||||
};
|
};
|
||||||
this.peopleSettings[PersonRole.Editor] = personSettings;
|
this.peopleSettings[PersonRole.Editor] = personSettings;
|
||||||
|
|
||||||
personSettings = this.createBlankPersonSettings('inker');
|
personSettings = this.createBlankPersonSettings('inker');
|
||||||
personSettings.fetchFn = (filter: string) => {
|
personSettings.fetchFn = (filter: string) => {
|
||||||
return of (this.persons.filter(p => p.value.role == PersonRole.Inker && this.utilityService.filter(p.title, filter)))
|
return this.fetchPeople(PersonRole.Inker, filter);
|
||||||
};
|
};
|
||||||
this.peopleSettings[PersonRole.Inker] = personSettings;
|
this.peopleSettings[PersonRole.Inker] = personSettings;
|
||||||
|
|
||||||
personSettings = this.createBlankPersonSettings('letterer');
|
personSettings = this.createBlankPersonSettings('letterer');
|
||||||
personSettings.fetchFn = (filter: string) => {
|
personSettings.fetchFn = (filter: string) => {
|
||||||
return of (this.persons.filter(p => p.value.role == PersonRole.Letterer && this.utilityService.filter(p.title, filter)))
|
return this.fetchPeople(PersonRole.Letterer, filter);
|
||||||
};
|
};
|
||||||
this.peopleSettings[PersonRole.Letterer] = personSettings;
|
this.peopleSettings[PersonRole.Letterer] = personSettings;
|
||||||
|
|
||||||
personSettings = this.createBlankPersonSettings('penciller');
|
personSettings = this.createBlankPersonSettings('penciller');
|
||||||
personSettings.fetchFn = (filter: string) => {
|
personSettings.fetchFn = (filter: string) => {
|
||||||
return of (this.persons.filter(p => p.value.role == PersonRole.Penciller && this.utilityService.filter(p.title, filter)))
|
return this.fetchPeople(PersonRole.Penciller, filter);
|
||||||
};
|
};
|
||||||
this.peopleSettings[PersonRole.Penciller] = personSettings;
|
this.peopleSettings[PersonRole.Penciller] = personSettings;
|
||||||
|
|
||||||
personSettings = this.createBlankPersonSettings('publisher');
|
personSettings = this.createBlankPersonSettings('publisher');
|
||||||
personSettings.fetchFn = (filter: string) => {
|
personSettings.fetchFn = (filter: string) => {
|
||||||
return of (this.persons.filter(p => p.value.role == PersonRole.Publisher && this.utilityService.filter(p.title, filter)))
|
return this.fetchPeople(PersonRole.Publisher, filter);
|
||||||
};
|
};
|
||||||
this.peopleSettings[PersonRole.Publisher] = personSettings;
|
this.peopleSettings[PersonRole.Publisher] = personSettings;
|
||||||
|
|
||||||
|
personSettings = this.createBlankPersonSettings('translators');
|
||||||
|
personSettings.fetchFn = (filter: string) => {
|
||||||
|
return this.fetchPeople(PersonRole.Translator, filter);
|
||||||
|
};
|
||||||
|
this.peopleSettings[PersonRole.Translator] = personSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPeople(role: PersonRole, filter: string): Observable<FilterItem<Person>[]> {
|
||||||
|
return this.metadataService.getAllPeople(this.filter.libraries).pipe(map(people => {
|
||||||
|
return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter)).map((p: Person) => {
|
||||||
|
return {
|
||||||
|
title: p.name,
|
||||||
|
value: p,
|
||||||
|
selected: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
createBlankPersonSettings(id: string) {
|
createBlankPersonSettings(id: string) {
|
||||||
@ -325,6 +458,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
this.filter.genres = genres.map(item => item.value.id) || [];
|
this.filter.genres = genres.map(item => item.value.id) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateTagFilters(tags: FilterItem<Tag>[]) {
|
||||||
|
this.filter.tags = tags.map(item => item.value.id) || [];
|
||||||
|
}
|
||||||
|
|
||||||
updatePersonFilters(persons: FilterItem<Person>[], role: PersonRole) {
|
updatePersonFilters(persons: FilterItem<Person>[], role: PersonRole) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case PersonRole.CoverArtist:
|
case PersonRole.CoverArtist:
|
||||||
@ -357,6 +494,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
case PersonRole.Writer:
|
case PersonRole.Writer:
|
||||||
this.filter.writers = persons.map(p => p.value.id);
|
this.filter.writers = persons.map(p => p.value.id);
|
||||||
break;
|
break;
|
||||||
|
case PersonRole.Translator:
|
||||||
|
this.filter.translators = persons.map(p => p.value.id);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -369,6 +508,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
this.filter.rating = rating;
|
this.filter.rating = rating;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAgeRating(ratingDtos: FilterItem<AgeRatingDto>[]) {
|
||||||
|
this.filter.ageRating = ratingDtos.map(item => item.value.value) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLanguageRating(languages: FilterItem<Language>[]) {
|
||||||
|
this.filter.languages = languages.map(item => item.value.isoCode) || [];
|
||||||
|
}
|
||||||
|
|
||||||
updateReadStatus(status: string) {
|
updateReadStatus(status: string) {
|
||||||
console.log('readstatus: ', this.filter.readStatus);
|
console.log('readstatus: ', this.filter.readStatus);
|
||||||
if (status === 'read') {
|
if (status === 'read') {
|
||||||
@ -380,13 +527,26 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSortOrder() {
|
||||||
|
this.isAscendingSort = !this.isAscendingSort;
|
||||||
|
if (this.filter.sortOptions !== null) {
|
||||||
|
this.filter.sortOptions.isAscending = this.isAscendingSort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getPersonsSettings(role: PersonRole) {
|
getPersonsSettings(role: PersonRole) {
|
||||||
return this.peopleSettings[role];
|
return this.peopleSettings[role];
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.filter = this.seriesService.createSeriesFilter();
|
this.filter = this.seriesService.createSeriesFilter();
|
||||||
|
this.readProgressGroup.get('read')?.setValue(true);
|
||||||
|
this.readProgressGroup.get('notRead')?.setValue(true);
|
||||||
|
this.readProgressGroup.get('inProgress')?.setValue(true);
|
||||||
|
this.sortGroup.get('sortField')?.setValue(SortField.SortName);
|
||||||
|
this.isAscendingSort = true;
|
||||||
this.resetTypeaheads.next(true);
|
this.resetTypeaheads.next(true);
|
||||||
|
|
||||||
this.applyFilter.emit(this.filter);
|
this.applyFilter.emit(this.filter);
|
||||||
this.updateApplied++;
|
this.updateApplied++;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
@use '../../../theme/colors';
|
@use '../../../theme/colors';
|
||||||
|
|
||||||
$triangle-size: 40px;
|
$triangle-size: 30px;
|
||||||
$image-height: 230px;
|
$image-height: 230px;
|
||||||
$image-width: 160px;
|
$image-width: 160px;
|
||||||
|
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
[isLoading]="isLoading"
|
[isLoading]="isLoading"
|
||||||
[items]="series"
|
[items]="series"
|
||||||
[pagination]="seriesPagination"
|
[pagination]="seriesPagination"
|
||||||
|
[filterSettings]="filterSettings"
|
||||||
(pageChange)="onPageChange($event)"
|
(pageChange)="onPageChange($event)"
|
||||||
(applyFilter)="updateFilter($event)"
|
(applyFilter)="updateFilter($event)"
|
||||||
>
|
>
|
||||||
|
@ -6,6 +6,7 @@ import { ToastrService } from 'ngx-toastr';
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||||
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
||||||
|
import { FilterSettings } from 'src/app/cards/card-detail-layout/card-detail-layout.component';
|
||||||
import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
|
import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
|
||||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||||
@ -38,6 +39,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
|||||||
collectionTagActions: ActionItem<CollectionTag>[] = [];
|
collectionTagActions: ActionItem<CollectionTag>[] = [];
|
||||||
isAdmin: boolean = false;
|
isAdmin: boolean = false;
|
||||||
filter: SeriesFilter | undefined = undefined;
|
filter: SeriesFilter | undefined = undefined;
|
||||||
|
filterSettings: FilterSettings = new FilterSettings();
|
||||||
|
|
||||||
private onDestory: Subject<void> = new Subject<void>();
|
private onDestory: Subject<void> = new Subject<void>();
|
||||||
|
|
||||||
@ -95,6 +97,9 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tagId = parseInt(routeId, 10);
|
const tagId = parseInt(routeId, 10);
|
||||||
|
|
||||||
|
this.filterSettings.presetCollectionId = tagId;
|
||||||
|
|
||||||
this.updateTag(tagId);
|
this.updateTag(tagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
[items]="series"
|
[items]="series"
|
||||||
[actions]="actions"
|
[actions]="actions"
|
||||||
[pagination]="pagination"
|
[pagination]="pagination"
|
||||||
|
[filterSettings]="filterSettings"
|
||||||
(applyFilter)="updateFilter($event)"
|
(applyFilter)="updateFilter($event)"
|
||||||
(pageChange)="onPageChange($event)"
|
(pageChange)="onPageChange($event)"
|
||||||
>
|
>
|
||||||
|
@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||||
|
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||||
import { Library } from '../_models/library';
|
import { Library } from '../_models/library';
|
||||||
@ -31,6 +32,7 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
|||||||
actions: ActionItem<Library>[] = [];
|
actions: ActionItem<Library>[] = [];
|
||||||
filter: SeriesFilter | undefined = undefined;
|
filter: SeriesFilter | undefined = undefined;
|
||||||
onDestroy: Subject<void> = new Subject<void>();
|
onDestroy: Subject<void> = new Subject<void>();
|
||||||
|
filterSettings: FilterSettings = new FilterSettings();
|
||||||
|
|
||||||
bulkActionCallback = (action: Action, data: any) => {
|
bulkActionCallback = (action: Action, data: any) => {
|
||||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||||
@ -85,6 +87,8 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
|
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
|
||||||
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||||
|
this.filterSettings.presetLibraryId = this.libraryId;
|
||||||
|
|
||||||
this.loadPage();
|
this.loadPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,7 +151,12 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.loadingSeries = true;
|
this.loadingSeries = true;
|
||||||
|
|
||||||
this.seriesService.getSeriesForLibrary(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
if (this.filter == undefined) {
|
||||||
|
this.filter = this.seriesService.createSeriesFilter();
|
||||||
|
this.filter.libraries.push(this.libraryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.seriesService.getSeriesForLibrary(0, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
||||||
this.series = series.result;
|
this.series = series.result;
|
||||||
this.pagination = series.pagination;
|
this.pagination = series.pagination;
|
||||||
this.loadingSeries = false;
|
this.loadingSeries = false;
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</app-carousel-reel>
|
</app-carousel-reel>
|
||||||
|
|
||||||
<app-carousel-reel [items]="libraries" title="Libraries">
|
<app-carousel-reel [items]="libraries" title="Libraries" (sectionClick)="handleSectionClick($event)">
|
||||||
<ng-template #carouselItem let-item let-position="idx">
|
<ng-template #carouselItem let-item let-position="idx">
|
||||||
<app-library-card [data]="item"></app-library-card>
|
<app-library-card [data]="item"></app-library-card>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -110,6 +110,8 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
|||||||
this.router.navigate(['recently-added']);
|
this.router.navigate(['recently-added']);
|
||||||
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
||||||
this.router.navigate(['on-deck']);
|
this.router.navigate(['on-deck']);
|
||||||
|
} else if (sectionTitle.toLowerCase() === 'libraries') {
|
||||||
|
this.router.navigate(['all-series']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,6 +34,7 @@ export class OnDeckComponent implements OnInit {
|
|||||||
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||||
}
|
}
|
||||||
this.filterSettings.readProgressDisabled = true;
|
this.filterSettings.readProgressDisabled = true;
|
||||||
|
this.filterSettings.sortDisabled = true;
|
||||||
this.loadPage();
|
this.loadPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
[isLoading]="isLoading"
|
[isLoading]="isLoading"
|
||||||
[items]="series"
|
[items]="series"
|
||||||
[pagination]="pagination"
|
[pagination]="pagination"
|
||||||
|
[filterSettings]="filterSettings"
|
||||||
(applyFilter)="applyFilter($event)"
|
(applyFilter)="applyFilter($event)"
|
||||||
(pageChange)="onPageChange($event)"
|
(pageChange)="onPageChange($event)"
|
||||||
>
|
>
|
||||||
|
@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||||
|
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||||
import { Pagination } from '../_models/pagination';
|
import { Pagination } from '../_models/pagination';
|
||||||
@ -30,6 +31,7 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy {
|
|||||||
libraryId!: number;
|
libraryId!: number;
|
||||||
|
|
||||||
filter: SeriesFilter | undefined = undefined;
|
filter: SeriesFilter | undefined = undefined;
|
||||||
|
filterSettings: FilterSettings = new FilterSettings();
|
||||||
|
|
||||||
onDestroy: Subject<void> = new Subject();
|
onDestroy: Subject<void> = new Subject();
|
||||||
|
|
||||||
@ -40,6 +42,8 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy {
|
|||||||
if (this.pagination === undefined || this.pagination === null) {
|
if (this.pagination === undefined || this.pagination === null) {
|
||||||
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||||
}
|
}
|
||||||
|
this.filterSettings.sortDisabled = true;
|
||||||
|
|
||||||
this.loadPage();
|
this.loadPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
<div class="row no-gutters {{series?.userReview ? '' : 'mt-2'}}">
|
<div class="row no-gutters mt-2 mb-2">
|
||||||
<app-read-more [text]="seriesSummary" [maxLength]="250"></app-read-more>
|
<app-read-more [text]="seriesSummary" [maxLength]="250"></app-read-more>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- This first row will have random information about the series-->
|
<!-- This first row will have random information about the series-->
|
||||||
<div class="row no-gutters" *ngIf="seriesMetadata.ageRating">
|
<div class="row no-gutters mb-2">
|
||||||
<app-tag-badge title="Age Rating">{{ageRatingName}}</app-tag-badge>
|
<app-tag-badge title="Age Rating" *ngIf="seriesMetadata.ageRating">{{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}}</app-tag-badge>
|
||||||
<ng-container *ngIf="series">
|
<ng-container *ngIf="series">
|
||||||
<!-- Maybe we can put the library this resides in to make it easier to get back -->
|
<!-- Maybe we can put the library this resides in to make it easier to get back -->
|
||||||
<!-- tooltip here explaining how this is year of first issue -->
|
<!-- tooltip here explaining how this is year of first issue -->
|
||||||
<app-tag-badge *ngIf="seriesMetadata.releaseYear > 0" title="Release date">{{seriesMetadata.releaseYear}}</app-tag-badge>
|
<app-tag-badge *ngIf="seriesMetadata.releaseYear > 0" title="Release date">{{seriesMetadata.releaseYear}}</app-tag-badge>
|
||||||
|
<app-tag-badge *ngIf="seriesMetadata.language !== ''" title="Language">{{seriesMetadata.language}}</app-tag-badge>
|
||||||
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed">
|
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed">
|
||||||
<app-series-format [format]="series.format">{{utilityService.mangaFormat(series.format)}}</app-series-format>
|
<app-series-format [format]="series.format">{{utilityService.mangaFormat(series.format)}}</app-series-format>
|
||||||
</app-tag-badge>
|
</app-tag-badge>
|
||||||
@ -20,17 +21,25 @@
|
|||||||
<h5>Genres</h5>
|
<h5>Genres</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-tag-badge *ngFor="let genre of seriesMetadata.genres" [selectionMode]="TagBadgeCursor.Clickable">{{genre.title}}</app-tag-badge>
|
<app-badge-expander [items]="seriesMetadata.genres">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-tag-badge [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.tags && seriesMetadata.tags.length > 0">
|
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.collectionTags && seriesMetadata.collectionTags.length > 0">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<h5>Collections</h5>
|
<h5>Collections</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-tag-badge *ngFor="let tag of seriesMetadata.tags" a11y-click="13,32" class="clickable" routerLink="/collections/{{tag.id}}" [selectionMode]="TagBadgeCursor.Clickable">
|
<app-badge-expander [items]="seriesMetadata.collectionTags">
|
||||||
{{tag.title}}
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
</app-tag-badge>
|
<app-tag-badge a11y-click="13,32" class="clickable" routerLink="/collections/{{item.id}}" [selectionMode]="TagBadgeCursor.Clickable">
|
||||||
|
{{item.title}}
|
||||||
|
</app-tag-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.writers && seriesMetadata.writers.length > 0">
|
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.writers && seriesMetadata.writers.length > 0">
|
||||||
@ -38,12 +47,16 @@
|
|||||||
<h5>Authors</h5>
|
<h5>Authors</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-person-badge *ngFor="let person of seriesMetadata.writers" [person]="person"></app-person-badge>
|
<app-badge-expander [items]="seriesMetadata.writers">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-person-badge [person]="item"></app-person-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row no-gutters">
|
<div class="row no-gutters">
|
||||||
<hr class="col-md-11">
|
<hr class="col-md-11" *ngIf="hasExtendedProperites" >
|
||||||
<a [class.hidden]="hasExtendedProperites" *ngIf="hasExtendedProperites" class="col-md-1 read-more-link" (click)="toggleView()"> <i aria-hidden="true" class="fa fa-caret-{{isCollapsed ? 'down' : 'up'}}" aria-controls="extended-series-metadata"></i> See {{isCollapsed ? 'More' : 'Less'}}</a>
|
<a [class.hidden]="hasExtendedProperites" *ngIf="hasExtendedProperites" class="col-md-1 read-more-link" (click)="toggleView()"> <i aria-hidden="true" class="fa fa-caret-{{isCollapsed ? 'down' : 'up'}}" aria-controls="extended-series-metadata"></i> See {{isCollapsed ? 'More' : 'Less'}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -53,7 +66,11 @@
|
|||||||
<h5>Artists</h5>
|
<h5>Artists</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-person-badge *ngFor="let person of seriesMetadata.artists" [person]="person"></app-person-badge>
|
<app-badge-expander [items]="seriesMetadata.artists">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-person-badge [person]="item"></app-person-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -62,7 +79,11 @@
|
|||||||
<h5>Characters</h5>
|
<h5>Characters</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-person-badge *ngFor="let person of seriesMetadata.characters" [person]="person"></app-person-badge>
|
<app-badge-expander [items]="seriesMetadata.characters">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-person-badge [person]="item"></app-person-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -71,7 +92,11 @@
|
|||||||
<h5>Colorists</h5>
|
<h5>Colorists</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-person-badge *ngFor="let person of seriesMetadata.colorists" [person]="person"></app-person-badge>
|
<app-badge-expander [items]="seriesMetadata.colorists">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-person-badge [person]="item"></app-person-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -80,7 +105,11 @@
|
|||||||
<h5>Editors</h5>
|
<h5>Editors</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-person-badge *ngFor="let person of seriesMetadata.editors" [person]="person"></app-person-badge>
|
<app-badge-expander [items]="seriesMetadata.editors">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-person-badge [person]="item"></app-person-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -89,7 +118,11 @@
|
|||||||
<h5>Inkers</h5>
|
<h5>Inkers</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-person-badge *ngFor="let person of seriesMetadata.inkers" [person]="person"></app-person-badge>
|
<app-badge-expander [items]="seriesMetadata.inkers">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-person-badge [person]="item"></app-person-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -98,7 +131,35 @@
|
|||||||
<h5>Letterers</h5>
|
<h5>Letterers</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-person-badge *ngFor="let person of seriesMetadata.letterers" [person]="person"></app-person-badge>
|
<app-badge-expander [items]="seriesMetadata.letterers">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-person-badge [person]="item"></app-person-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row no-gutters" *ngIf="seriesMetadata.tags && seriesMetadata.tags.length > 0">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h5>Tags</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<app-badge-expander [items]="seriesMetadata.tags">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-tag-badge [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.translators && seriesMetadata.translators.length > 0">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h5>Translators</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<app-badge-expander [items]="seriesMetadata.translators">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-person-badge [person]="item"></app-person-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -107,7 +168,11 @@
|
|||||||
<h5>Pencillers</h5>
|
<h5>Pencillers</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-person-badge *ngFor="let person of seriesMetadata.pencillers" [person]="person"></app-person-badge>
|
<app-badge-expander [items]="seriesMetadata.pencillers">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-person-badge [person]="item"></app-person-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -116,7 +181,11 @@
|
|||||||
<h5>Publishers</h5>
|
<h5>Publishers</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-person-badge *ngFor="let person of seriesMetadata.publishers" [person]="person"></app-person-badge>
|
<app-badge-expander [items]="seriesMetadata.publishers">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
|
<app-person-badge [person]="item"></app-person-badge>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
@ -19,10 +19,6 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
|||||||
isCollapsed: boolean = true;
|
isCollapsed: boolean = true;
|
||||||
hasExtendedProperites: boolean = false;
|
hasExtendedProperites: boolean = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* String representation of AgeRating enum
|
|
||||||
*/
|
|
||||||
ageRatingName: string = '';
|
|
||||||
/**
|
/**
|
||||||
* Html representation of Series Summary
|
* Html representation of Series Summary
|
||||||
*/
|
*/
|
||||||
@ -36,7 +32,7 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
|||||||
return TagBadgeCursor;
|
return TagBadgeCursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(public utilityService: UtilityService, private metadataService: MetadataService) { }
|
constructor(public utilityService: UtilityService, public metadataService: MetadataService) { }
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
this.hasExtendedProperites = this.seriesMetadata.colorists.length > 0 ||
|
this.hasExtendedProperites = this.seriesMetadata.colorists.length > 0 ||
|
||||||
@ -45,11 +41,9 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
|||||||
this.seriesMetadata.inkers.length > 0 ||
|
this.seriesMetadata.inkers.length > 0 ||
|
||||||
this.seriesMetadata.letterers.length > 0 ||
|
this.seriesMetadata.letterers.length > 0 ||
|
||||||
this.seriesMetadata.pencillers.length > 0 ||
|
this.seriesMetadata.pencillers.length > 0 ||
|
||||||
this.seriesMetadata.publishers.length > 0;
|
this.seriesMetadata.publishers.length > 0 ||
|
||||||
|
this.seriesMetadata.translators.length > 0 ||
|
||||||
this.metadataService.getAgeRating(this.seriesMetadata.ageRating).subscribe(rating => {
|
this.seriesMetadata.tags.length > 0;
|
||||||
this.ageRatingName = rating;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.seriesMetadata !== null) {
|
if (this.seriesMetadata !== null) {
|
||||||
this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '<br>');
|
this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '<br>');
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
<div class="badge-expander">
|
||||||
|
<div class="content">
|
||||||
|
<ng-container *ngFor="let item of visibleItems; index as i;" [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||||
|
<button type="button" *ngIf="!isCollapsed && itemsLeft !== 0" class="btn btn-outline-primary" (click)="toggleVisible()" [attr.aria-expanded]="!isCollapsed">
|
||||||
|
and {{itemsLeft}} more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,12 @@
|
|||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed {
|
||||||
|
height: 35px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-expander {
|
||||||
|
//display: inline-block;
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
import { Component, ContentChild, Input, OnInit, TemplateRef } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-badge-expander',
|
||||||
|
templateUrl: './badge-expander.component.html',
|
||||||
|
styleUrls: ['./badge-expander.component.scss']
|
||||||
|
})
|
||||||
|
export class BadgeExpanderComponent implements OnInit {
|
||||||
|
|
||||||
|
@Input() items: Array<any> = [];
|
||||||
|
@ContentChild('badgeExpanderItem') itemTemplate!: TemplateRef<any>;
|
||||||
|
|
||||||
|
|
||||||
|
visibleItems: Array<any> = [];
|
||||||
|
isCollapsed: boolean = false;
|
||||||
|
|
||||||
|
get itemsLeft() {
|
||||||
|
return Math.max(this.items.length - 4, 0);
|
||||||
|
}
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.visibleItems = this.items.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleVisible() {
|
||||||
|
this.isCollapsed = !this.isCollapsed;
|
||||||
|
|
||||||
|
this.visibleItems = this.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -17,6 +17,7 @@ import { CircularLoaderComponent } from './circular-loader/circular-loader.compo
|
|||||||
import { NgCircleProgressModule } from 'ng-circle-progress';
|
import { NgCircleProgressModule } from 'ng-circle-progress';
|
||||||
import { SentenceCasePipe } from './sentence-case.pipe';
|
import { SentenceCasePipe } from './sentence-case.pipe';
|
||||||
import { PersonBadgeComponent } from './person-badge/person-badge.component';
|
import { PersonBadgeComponent } from './person-badge/person-badge.component';
|
||||||
|
import { BadgeExpanderComponent } from './badge-expander/badge-expander.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
@ -32,7 +33,8 @@ import { PersonBadgeComponent } from './person-badge/person-badge.component';
|
|||||||
UpdateNotificationModalComponent,
|
UpdateNotificationModalComponent,
|
||||||
CircularLoaderComponent,
|
CircularLoaderComponent,
|
||||||
SentenceCasePipe,
|
SentenceCasePipe,
|
||||||
PersonBadgeComponent
|
PersonBadgeComponent,
|
||||||
|
BadgeExpanderComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@ -55,7 +57,8 @@ import { PersonBadgeComponent } from './person-badge/person-badge.component';
|
|||||||
SeriesFormatComponent,
|
SeriesFormatComponent,
|
||||||
TagBadgeComponent,
|
TagBadgeComponent,
|
||||||
CircularLoaderComponent,
|
CircularLoaderComponent,
|
||||||
PersonBadgeComponent
|
PersonBadgeComponent,
|
||||||
|
BadgeExpanderComponent
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SharedModule { }
|
export class SharedModule { }
|
||||||
|
@ -35,6 +35,9 @@ input {
|
|||||||
line-height: inherit !important;
|
line-height: inherit !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
input:empty {
|
||||||
|
padding-top: 6px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .bg-dark .typeahead-input {
|
::ng-deep .bg-dark .typeahead-input {
|
||||||
@ -62,7 +65,7 @@ input {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
.list-group-item {
|
.list-group-item {
|
||||||
padding: 5px 5px;
|
padding: 5px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -254,7 +254,7 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
|
|
||||||
@HostListener('window:click', ['$event'])
|
@HostListener('window:click', ['$event'])
|
||||||
handleDocumentClick() {
|
handleDocumentClick(event: any) {
|
||||||
this.hasFocus = false;
|
this.hasFocus = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -370,6 +370,8 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.inputElem) {
|
if (this.inputElem) {
|
||||||
|
// hack: To prevent multiple typeaheads from being open at once, click document then trigger the focus
|
||||||
|
document.querySelector('body')?.click();
|
||||||
this.inputElem.nativeElement.focus();
|
this.inputElem.nativeElement.focus();
|
||||||
this.hasFocus = true;
|
this.hasFocus = true;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user