Collection Rework (#2830)

This commit is contained in:
Joe Milazzo 2024-04-06 12:03:49 -05:00 committed by GitHub
parent 0dacc061f1
commit deaaccb96a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
93 changed files with 5413 additions and 1120 deletions

View File

@ -10,6 +10,7 @@ using API.Helpers;
using API.Helpers.Builders; using API.Helpers.Builders;
using API.Services; using API.Services;
using AutoMapper; using AutoMapper;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@ -47,6 +48,7 @@ public abstract class AbstractDbTest
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>()); var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
var mapper = config.CreateMapper(); var mapper = config.CreateMapper();
_unitOfWork = new UnitOfWork(_context, mapper, null); _unitOfWork = new UnitOfWork(_context, mapper, null);
} }

View File

@ -74,10 +74,10 @@ public class EnumerableExtensionsTests
new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"}, new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"},
new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"} new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"}
)] )]
[InlineData( [InlineData(
new[] {"01/001.jpg", "001.jpg"}, new[] {"01/001.jpg", "001.jpg"},
new[] {"001.jpg", "01/001.jpg"} new[] {"001.jpg", "01/001.jpg"}
)] )]
public void TestNaturalSort(string[] input, string[] expected) public void TestNaturalSort(string[] input, string[] expected)
{ {
Assert.Equal(expected, input.OrderByNatural(x => x).ToArray()); Assert.Equal(expected, input.OrderByNatural(x => x).ToArray());

View File

@ -45,17 +45,17 @@ public class QueryableExtensionsTests
[InlineData(false, 1)] [InlineData(false, 1)]
public void RestrictAgainstAgeRestriction_CollectionTag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) public void RestrictAgainstAgeRestriction_CollectionTag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount)
{ {
var items = new List<CollectionTag>() var items = new List<AppUserCollection>()
{ {
new CollectionTagBuilder("Test") new AppUserCollectionBuilder("Test")
.WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) .WithItem(new SeriesBuilder("S1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build())
.Build(), .Build(),
new CollectionTagBuilder("Test 2") new AppUserCollectionBuilder("Test 2")
.WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()) .WithItem(new SeriesBuilder("S2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()).Build())
.WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) .WithItem(new SeriesBuilder("S1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build())
.Build(), .Build(),
new CollectionTagBuilder("Test 3") new AppUserCollectionBuilder("Test 3")
.WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()) .WithItem(new SeriesBuilder("S3").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()).Build())
.Build(), .Build(),
}; };

View File

@ -114,65 +114,65 @@ public class CollectionTagRepositoryTests
#endregion #endregion
#region RemoveTagsWithoutSeries // #region RemoveTagsWithoutSeries
//
[Fact] // [Fact]
public async Task RemoveTagsWithoutSeries_ShouldRemoveTags() // public async Task RemoveTagsWithoutSeries_ShouldRemoveTags()
{ // {
var library = new LibraryBuilder("Test", LibraryType.Manga).Build(); // var library = new LibraryBuilder("Test", LibraryType.Manga).Build();
var series = new SeriesBuilder("Test 1").Build(); // var series = new SeriesBuilder("Test 1").Build();
var commonTag = new CollectionTagBuilder("Tag 1").Build(); // var commonTag = new AppUserCollectionBuilder("Tag 1").Build();
series.Metadata.CollectionTags.Add(commonTag); // series.Metadata.CollectionTags.Add(commonTag);
series.Metadata.CollectionTags.Add(new CollectionTagBuilder("Tag 2").Build()); // series.Metadata.CollectionTags.Add(new AppUserCollectionBuilder("Tag 2").Build());
//
var series2 = new SeriesBuilder("Test 1").Build(); // var series2 = new SeriesBuilder("Test 1").Build();
series2.Metadata.CollectionTags.Add(commonTag); // series2.Metadata.CollectionTags.Add(commonTag);
library.Series.Add(series); // library.Series.Add(series);
library.Series.Add(series2); // library.Series.Add(series2);
_unitOfWork.LibraryRepository.Add(library); // _unitOfWork.LibraryRepository.Add(library);
await _unitOfWork.CommitAsync(); // await _unitOfWork.CommitAsync();
//
Assert.Equal(2, series.Metadata.CollectionTags.Count); // Assert.Equal(2, series.Metadata.CollectionTags.Count);
Assert.Single(series2.Metadata.CollectionTags); // Assert.Single(series2.Metadata.CollectionTags);
//
// Delete both series // // Delete both series
_unitOfWork.SeriesRepository.Remove(series); // _unitOfWork.SeriesRepository.Remove(series);
_unitOfWork.SeriesRepository.Remove(series2); // _unitOfWork.SeriesRepository.Remove(series2);
//
await _unitOfWork.CommitAsync(); // await _unitOfWork.CommitAsync();
//
// Validate that both tags exist // // Validate that both tags exist
Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count()); // Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count());
//
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); // await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
//
Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()); // Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync());
} // }
//
[Fact] // [Fact]
public async Task RemoveTagsWithoutSeries_ShouldNotRemoveTags() // public async Task RemoveTagsWithoutSeries_ShouldNotRemoveTags()
{ // {
var library = new LibraryBuilder("Test", LibraryType.Manga).Build(); // var library = new LibraryBuilder("Test", LibraryType.Manga).Build();
var series = new SeriesBuilder("Test 1").Build(); // var series = new SeriesBuilder("Test 1").Build();
var commonTag = new CollectionTagBuilder("Tag 1").Build(); // var commonTag = new AppUserCollectionBuilder("Tag 1").Build();
series.Metadata.CollectionTags.Add(commonTag); // series.Metadata.CollectionTags.Add(commonTag);
series.Metadata.CollectionTags.Add(new CollectionTagBuilder("Tag 2").Build()); // series.Metadata.CollectionTags.Add(new AppUserCollectionBuilder("Tag 2").Build());
//
var series2 = new SeriesBuilder("Test 1").Build(); // var series2 = new SeriesBuilder("Test 1").Build();
series2.Metadata.CollectionTags.Add(commonTag); // series2.Metadata.CollectionTags.Add(commonTag);
library.Series.Add(series); // library.Series.Add(series);
library.Series.Add(series2); // library.Series.Add(series2);
_unitOfWork.LibraryRepository.Add(library); // _unitOfWork.LibraryRepository.Add(library);
await _unitOfWork.CommitAsync(); // await _unitOfWork.CommitAsync();
//
Assert.Equal(2, series.Metadata.CollectionTags.Count); // Assert.Equal(2, series.Metadata.CollectionTags.Count);
Assert.Single(series2.Metadata.CollectionTags); // Assert.Single(series2.Metadata.CollectionTags);
//
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); // await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
//
// Validate that both tags exist // // Validate that both tags exist
Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count()); // Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count());
} // }
//
#endregion // #endregion
} }

View File

@ -167,53 +167,53 @@ public class CleanupServiceTests : AbstractDbTest
} }
#endregion #endregion
#region DeleteTagCoverImages // #region DeleteTagCoverImages
//
[Fact] // [Fact]
public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles() // public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles()
{ // {
var filesystem = CreateFileSystem(); // var filesystem = CreateFileSystem();
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1)}.jpg", new MockFileData("")); // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1)}.jpg", new MockFileData(""));
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(2)}.jpg", new MockFileData("")); // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(2)}.jpg", new MockFileData(""));
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData("")); // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData(""));
//
// Delete all Series to reset state // // Delete all Series to reset state
await ResetDb(); // await ResetDb();
//
// Add 2 series with cover images // // Add 2 series with cover images
//
_context.Series.Add(new SeriesBuilder("Test 1") // _context.Series.Add(new SeriesBuilder("Test 1")
.WithMetadata(new SeriesMetadataBuilder() // .WithMetadata(new SeriesMetadataBuilder()
.WithCollectionTag(new CollectionTagBuilder("Something") // .WithCollectionTag(new AppUserCollectionBuilder("Something")
.WithCoverImage($"{ImageService.GetCollectionTagFormat(1)}.jpg") // .WithCoverImage($"{ImageService.GetCollectionTagFormat(1)}.jpg")
.Build()) // .Build())
.Build()) // .Build())
.WithCoverImage($"{ImageService.GetSeriesFormat(1)}.jpg") // .WithCoverImage($"{ImageService.GetSeriesFormat(1)}.jpg")
.WithLibraryId(1) // .WithLibraryId(1)
.Build()); // .Build());
//
_context.Series.Add(new SeriesBuilder("Test 2") // _context.Series.Add(new SeriesBuilder("Test 2")
.WithMetadata(new SeriesMetadataBuilder() // .WithMetadata(new SeriesMetadataBuilder()
.WithCollectionTag(new CollectionTagBuilder("Something") // .WithCollectionTag(new AppUserCollectionBuilder("Something")
.WithCoverImage($"{ImageService.GetCollectionTagFormat(2)}.jpg") // .WithCoverImage($"{ImageService.GetCollectionTagFormat(2)}.jpg")
.Build()) // .Build())
.Build()) // .Build())
.WithCoverImage($"{ImageService.GetSeriesFormat(3)}.jpg") // .WithCoverImage($"{ImageService.GetSeriesFormat(3)}.jpg")
.WithLibraryId(1) // .WithLibraryId(1)
.Build()); // .Build());
//
//
await _context.SaveChangesAsync(); // await _context.SaveChangesAsync();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem); // var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, // var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
ds); // ds);
//
await cleanupService.DeleteTagCoverImages(); // await cleanupService.DeleteTagCoverImages();
//
Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count()); // Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count());
} // }
//
#endregion // #endregion
#region DeleteReadingListCoverImages #region DeleteReadingListCoverImages
[Fact] [Fact]
@ -435,24 +435,26 @@ public class CleanupServiceTests : AbstractDbTest
[Fact] [Fact]
public async Task CleanupDbEntries_RemoveTagsWithoutSeries() public async Task CleanupDbEntries_RemoveTagsWithoutSeries()
{ {
var c = new CollectionTag() var s = new SeriesBuilder("Test")
.WithFormat(MangaFormat.Epub)
.WithMetadata(new SeriesMetadataBuilder().Build())
.Build();
s.Library = new LibraryBuilder("Test LIb").Build();
_context.Series.Add(s);
var c = new AppUserCollection()
{ {
Title = "Test Tag", Title = "Test Tag",
NormalizedTitle = "Test Tag".ToNormalized(), NormalizedTitle = "Test Tag".ToNormalized(),
AgeRating = AgeRating.Unknown,
Items = new List<Series>() {s}
}; };
var s = new SeriesBuilder("Test")
.WithFormat(MangaFormat.Epub)
.WithMetadata(new SeriesMetadataBuilder().WithCollectionTag(c).Build())
.Build();
s.Library = new LibraryBuilder("Test LIb").Build();
_context.Series.Add(s);
_context.AppUser.Add(new AppUser() _context.AppUser.Add(new AppUser()
{ {
UserName = "majora2007" UserName = "majora2007",
Collections = new List<AppUserCollection>() {c}
}); });
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork, var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
@ -465,7 +467,7 @@ public class CleanupServiceTests : AbstractDbTest
await cleanupService.CleanupDbEntries(); await cleanupService.CleanupDbEntries();
Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()); Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync());
} }
#endregion #endregion

View File

@ -3,13 +3,13 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs.CollectionTags; using API.DTOs.Collection;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Helpers.Builders; using API.Helpers.Builders;
using API.Services; using API.Services;
using API.Services.Plus;
using API.SignalR; using API.SignalR;
using API.Tests.Helpers;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
@ -25,7 +25,7 @@ public class CollectionTagServiceTests : AbstractDbTest
protected override async Task ResetDb() protected override async Task ResetDb()
{ {
_context.CollectionTag.RemoveRange(_context.CollectionTag.ToList()); _context.AppUserCollection.RemoveRange(_context.AppUserCollection.ToList());
_context.Library.RemoveRange(_context.Library.ToList()); _context.Library.RemoveRange(_context.Library.ToList());
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
@ -33,119 +33,148 @@ public class CollectionTagServiceTests : AbstractDbTest
private async Task SeedSeries() private async Task SeedSeries()
{ {
if (_context.CollectionTag.Any()) return; if (_context.AppUserCollection.Any()) return;
var s1 = new SeriesBuilder("Series 1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Mature).Build()).Build();
var s2 = new SeriesBuilder("Series 2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.G).Build()).Build();
_context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga) _context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga)
.WithSeries(new SeriesBuilder("Series 1").Build()) .WithSeries(s1)
.WithSeries(new SeriesBuilder("Series 2").Build()) .WithSeries(s2)
.Build()); .Build());
_context.CollectionTag.Add(new CollectionTagBuilder("Tag 1").Build()); var user = new AppUserBuilder("majora2007", "majora2007", Seed.DefaultThemes.First()).Build();
_context.CollectionTag.Add(new CollectionTagBuilder("Tag 2").WithIsPromoted(true).Build()); user.Collections = new List<AppUserCollection>()
{
new AppUserCollectionBuilder("Tag 1").WithItems(new []{s1}).Build(),
new AppUserCollectionBuilder("Tag 2").WithItems(new []{s1, s2}).WithIsPromoted(true).Build()
};
_unitOfWork.UserRepository.Add(user);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
} }
#region UpdateTag
[Fact]
public async Task TagExistsByName_ShouldFindTag()
{
await SeedSeries();
Assert.True(await _service.TagExistsByName("Tag 1"));
Assert.True(await _service.TagExistsByName("tag 1"));
Assert.False(await _service.TagExistsByName("tag5"));
}
[Fact] [Fact]
public async Task UpdateTag_ShouldUpdateFields() public async Task UpdateTag_ShouldUpdateFields()
{ {
await SeedSeries(); await SeedSeries();
_context.CollectionTag.Add(new CollectionTagBuilder("UpdateTag_ShouldUpdateFields").WithId(3).WithIsPromoted(true).Build()); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldUpdateFields").WithIsPromoted(true).Build());
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
await _service.UpdateTag(new CollectionTagDto() await _service.UpdateTag(new AppUserCollectionDto()
{ {
Title = "UpdateTag_ShouldUpdateFields", Title = "UpdateTag_ShouldUpdateFields",
Id = 3, Id = 3,
Promoted = true, Promoted = true,
Summary = "Test Summary", Summary = "Test Summary",
}); AgeRating = AgeRating.Unknown
}, 1);
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(3); var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3);
Assert.NotNull(tag); Assert.NotNull(tag);
Assert.True(tag.Promoted); Assert.True(tag.Promoted);
Assert.True(!string.IsNullOrEmpty(tag.Summary)); Assert.False(string.IsNullOrEmpty(tag.Summary));
} }
/// <summary>
/// UpdateTag should not change any title if non-Kavita source
/// </summary>
[Fact] [Fact]
public async Task AddTagToSeries_ShouldAddTagToAllSeries() public async Task UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource()
{ {
await SeedSeries(); await SeedSeries();
var ids = new[] {1, 2};
await _service.AddTagToSeries(await _unitOfWork.CollectionTagRepository.GetTagAsync(1, CollectionTagIncludes.SeriesMetadata), ids);
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(ids); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.Contains(metadatas.ElementAt(0).CollectionTags, t => t.Title.Equals("Tag 1")); Assert.NotNull(user);
Assert.Contains(metadatas.ElementAt(1).CollectionTags, t => t.Title.Equals("Tag 1"));
user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource").WithSource(ScrobbleProvider.Mal).Build());
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
await _service.UpdateTag(new AppUserCollectionDto()
{
Title = "New Title",
Id = 3,
Promoted = true,
Summary = "Test Summary",
AgeRating = AgeRating.Unknown
}, 1);
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3);
Assert.NotNull(tag);
Assert.Equal("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource", tag.Title);
Assert.False(string.IsNullOrEmpty(tag.Summary));
}
#endregion
#region RemoveTagFromSeries
[Fact]
public async Task RemoveTagFromSeries_RemoveSeriesFromTag()
{
await SeedSeries();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
// Tag 2 has 2 series
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2);
Assert.NotNull(tag);
await _service.RemoveTagFromSeries(tag, new[] {1});
var userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.Equal(2, userCollections!.Collections.Count);
Assert.Equal(1, tag.Items.Count);
Assert.Equal(2, tag.Items.First().Id);
} }
/// <summary>
/// Ensure the rating of the tag updates after a series change
/// </summary>
[Fact] [Fact]
public async Task RemoveTagFromSeries_ShouldRemoveMultiple() public async Task RemoveTagFromSeries_RemoveSeriesFromTag_UpdatesRating()
{ {
await SeedSeries(); await SeedSeries();
var ids = new[] {1, 2};
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(2, CollectionTagIncludes.SeriesMetadata); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
await _service.AddTagToSeries(tag, ids); Assert.NotNull(user);
// Tag 2 has 2 series
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2);
Assert.NotNull(tag);
await _service.RemoveTagFromSeries(tag, new[] {1}); await _service.RemoveTagFromSeries(tag, new[] {1});
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1}); Assert.Equal(AgeRating.G, tag.AgeRating);
Assert.Single(metadatas);
Assert.Empty(metadatas.First().CollectionTags);
Assert.NotEmpty(await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {2}));
} }
/// <summary>
/// Should remove the tag when there are no items left on the tag
/// </summary>
[Fact] [Fact]
public async Task GetTagOrCreate_ShouldReturnNewTag() public async Task RemoveTagFromSeries_RemoveSeriesFromTag_DeleteTagWhenNoSeriesLeft()
{ {
await SeedSeries(); await SeedSeries();
var tag = await _service.GetTagOrCreate(0, "GetTagOrCreate_ShouldReturnNewTag");
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
// Tag 1 has 1 series
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag); Assert.NotNull(tag);
Assert.Equal(0, tag.Id);
}
[Fact]
public async Task GetTagOrCreate_ShouldReturnExistingTag()
{
await SeedSeries();
var tag = await _service.GetTagOrCreate(1, "Some new tag");
Assert.NotNull(tag);
Assert.Equal(1, tag.Id);
Assert.Equal("Tag 1", tag.Title);
}
[Fact]
public async Task RemoveTagsWithoutSeries_ShouldRemoveAbandonedEntries()
{
await SeedSeries();
// Setup a tag with one series
var tag = await _service.GetTagOrCreate(0, "Tag with a series");
await _unitOfWork.CommitAsync();
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1});
tag.SeriesMetadatas.Add(metadatas.First());
var tagId = tag.Id;
await _unitOfWork.CommitAsync();
// Validate it doesn't remove tags it shouldn't
await _service.RemoveTagsWithoutSeries();
Assert.NotNull(await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId));
await _service.RemoveTagFromSeries(tag, new[] {1}); await _service.RemoveTagFromSeries(tag, new[] {1});
var tag2 = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1);
// Validate it does remove tags it should Assert.Null(tag2);
await _service.RemoveTagsWithoutSeries();
Assert.Null(await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId));
} }
#endregion
} }

View File

@ -768,7 +768,7 @@ public class SeriesServiceTests : AbstractDbTest
SeriesId = 1, SeriesId = 1,
Genres = new List<GenreTagDto> {new GenreTagDto {Id = 0, Title = "New Genre"}} Genres = new List<GenreTagDto> {new GenreTagDto {Id = 0, Title = "New Genre"}}
}, },
CollectionTags = new List<CollectionTagDto>()
}); });
Assert.True(success); Assert.True(success);
@ -777,46 +777,6 @@ public class SeriesServiceTests : AbstractDbTest
Assert.NotNull(series); Assert.NotNull(series);
Assert.NotNull(series.Metadata); Assert.NotNull(series.Metadata);
Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title));
}
[Fact]
public async Task UpdateSeriesMetadata_ShouldCreateNewTags_IfNoneExist()
{
await ResetDb();
var s = new SeriesBuilder("Test")
.Build();
s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build();
_context.Series.Add(s);
await _context.SaveChangesAsync();
var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto
{
SeriesMetadata = new SeriesMetadataDto
{
SeriesId = 1,
Genres = new List<GenreTagDto> {new GenreTagDto {Id = 0, Title = "New Genre"}},
Tags = new List<TagDto> {new TagDto {Id = 0, Title = "New Tag"}},
Characters = new List<PersonDto> {new PersonDto {Id = 0, Name = "Joe Shmo", Role = PersonRole.Character}},
Colorists = new List<PersonDto> {new PersonDto {Id = 0, Name = "Joe Shmo", Role = PersonRole.Colorist}},
Pencillers = new List<PersonDto> {new PersonDto {Id = 0, Name = "Joe Shmo 2", Role = PersonRole.Penciller}},
},
CollectionTags = new List<CollectionTagDto>
{
new CollectionTagDto {Id = 0, Promoted = false, Summary = string.Empty, CoverImageLocked = false, Title = "New Collection"}
}
});
Assert.True(success);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
Assert.NotNull(series.Metadata);
Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title));
Assert.True(series.Metadata.People.All(g => g.Name is "Joe Shmo" or "Joe Shmo 2"));
Assert.Contains("New Tag".SentenceCase(), series.Metadata.Tags.Select(g => g.Title));
Assert.Contains("New Collection", series.Metadata.CollectionTags.Select(g => g.Title));
} }
[Fact] [Fact]
@ -842,7 +802,7 @@ public class SeriesServiceTests : AbstractDbTest
SeriesId = 1, SeriesId = 1,
Genres = new List<GenreTagDto> {new () {Id = 0, Title = "New Genre"}}, Genres = new List<GenreTagDto> {new () {Id = 0, Title = "New Genre"}},
}, },
CollectionTags = new List<CollectionTagDto>()
}); });
Assert.True(success); Assert.True(success);
@ -875,7 +835,7 @@ public class SeriesServiceTests : AbstractDbTest
SeriesId = 1, SeriesId = 1,
Publishers = new List<PersonDto> {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, Publishers = new List<PersonDto> {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}},
}, },
CollectionTags = new List<CollectionTagDto>()
}); });
Assert.True(success); Assert.True(success);
@ -911,7 +871,7 @@ public class SeriesServiceTests : AbstractDbTest
Publishers = new List<PersonDto> {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, Publishers = new List<PersonDto> {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}},
PublisherLocked = true PublisherLocked = true
}, },
CollectionTags = new List<CollectionTagDto>()
}); });
Assert.True(success); Assert.True(success);
@ -944,7 +904,7 @@ public class SeriesServiceTests : AbstractDbTest
SeriesId = 1, SeriesId = 1,
Publishers = new List<PersonDto>(), Publishers = new List<PersonDto>(),
}, },
CollectionTags = new List<CollectionTagDto>()
}); });
Assert.True(success); Assert.True(success);
@ -978,7 +938,7 @@ public class SeriesServiceTests : AbstractDbTest
Genres = new List<GenreTagDto> {new () {Id = 1, Title = "Existing Genre"}}, Genres = new List<GenreTagDto> {new () {Id = 1, Title = "Existing Genre"}},
GenresLocked = true GenresLocked = true
}, },
CollectionTags = new List<CollectionTagDto>()
}); });
Assert.True(success); Assert.True(success);
@ -1007,7 +967,7 @@ public class SeriesServiceTests : AbstractDbTest
SeriesId = 1, SeriesId = 1,
ReleaseYear = 100, ReleaseYear = 100,
}, },
CollectionTags = new List<CollectionTagDto>()
}); });
Assert.True(success); Assert.True(success);

View File

@ -40,8 +40,14 @@ public static class PolicyConstants
/// </summary> /// </summary>
/// <remarks>This is used explicitly for Demo Server. Not sure why it would be used in another fashion</remarks> /// <remarks>This is used explicitly for Demo Server. Not sure why it would be used in another fashion</remarks>
public const string ReadOnlyRole = "Read Only"; public const string ReadOnlyRole = "Read Only";
/// <summary>
/// Ability to promote entities (Collections, Reading Lists, etc).
/// </summary>
public const string PromoteRole = "Promote";
public static readonly ImmutableArray<string> ValidRoles = public static readonly ImmutableArray<string> ValidRoles =
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole); ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole, PromoteRole);
} }

View File

@ -1,16 +1,18 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs.Collection; using API.DTOs.Collection;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.Entities.Metadata; using API.Entities;
using API.Extensions; using API.Extensions;
using API.Helpers.Builders;
using API.Services; using API.Services;
using API.Services.Plus; using API.Services.Plus;
using Kavita.Common; using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace API.Controllers; namespace API.Controllers;
@ -38,50 +40,37 @@ public class CollectionController : BaseApiController
} }
/// <summary> /// <summary>
/// Return a list of all collection tags on the server for the logged in user. /// Returns all Collection tags for a given User
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[HttpGet] [HttpGet]
public async Task<ActionResult<IEnumerable<CollectionTagDto>>> GetAllTags() public async Task<ActionResult<IEnumerable<AppUserCollectionDto>>> GetAllTags(bool ownedOnly = false)
{ {
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), !ownedOnly));
if (user == null) return Unauthorized();
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
if (isAdmin)
{
return Ok(await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync());
}
return Ok(await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(user.Id));
} }
/// <summary> /// <summary>
/// Searches against the collection tags on the DB and returns matches that meet the search criteria. /// Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned)
/// <remarks>Search strings will be cleaned of certain fields, like %</remarks>
/// </summary> /// </summary>
/// <param name="queryString">Search term</param> /// <param name="seriesId"></param>
/// <param name="ownedOnly"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")] [HttpGet("all-series")]
[HttpGet("search")] public async Task<ActionResult<IEnumerable<AppUserCollectionDto>>> GetCollectionsBySeries(int seriesId, bool ownedOnly = false)
public async Task<ActionResult<IEnumerable<CollectionTagDto>>> SearchTags(string? queryString)
{ {
queryString ??= string.Empty; return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosBySeriesAsync(User.GetUserId(), seriesId, !ownedOnly));
queryString = queryString.Replace(@"%", string.Empty);
if (queryString.Length == 0) return await GetAllTags();
return Ok(await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, User.GetUserId()));
} }
/// <summary> /// <summary>
/// Checks if a collection exists with the name /// Checks if a collection exists with the name
/// </summary> /// </summary>
/// <param name="name">If empty or null, will return true as that is invalid</param> /// <param name="name">If empty or null, will return true as that is invalid</param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("name-exists")] [HttpGet("name-exists")]
public async Task<ActionResult<bool>> DoesNameExists(string name) public async Task<ActionResult<bool>> DoesNameExists(string name)
{ {
return Ok(await _collectionService.TagExistsByName(name)); return Ok(await _unitOfWork.CollectionTagRepository.CollectionExists(name, User.GetUserId()));
} }
/// <summary> /// <summary>
@ -90,13 +79,15 @@ public class CollectionController : BaseApiController
/// </summary> /// </summary>
/// <param name="updatedTag"></param> /// <param name="updatedTag"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")] [HttpPost("update")]
public async Task<ActionResult> UpdateTag(CollectionTagDto updatedTag) public async Task<ActionResult> UpdateTag(AppUserCollectionDto updatedTag)
{ {
try try
{ {
if (await _collectionService.UpdateTag(updatedTag)) return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully")); if (await _collectionService.UpdateTag(updatedTag, User.GetUserId()))
{
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully"));
}
} }
catch (KavitaException ex) catch (KavitaException ex)
{ {
@ -107,18 +98,94 @@ public class CollectionController : BaseApiController
} }
/// <summary> /// <summary>
/// Adds a collection tag onto multiple Series. If tag id is 0, this will create a new tag. /// Promote/UnPromote multiple collections in one go. Will only update the authenticated user's collections and will only work if the user has promotion role
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("promote-multiple")]
public async Task<ActionResult> PromoteMultipleCollections(PromoteCollectionsDto dto)
{
// This needs to take into account owner as I can select other users cards
var collections = await _unitOfWork.CollectionTagRepository.GetCollectionsByIds(dto.CollectionIds);
var userId = User.GetUserId();
if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole))
{
return BadRequest(await _localizationService.Translate(userId, "permission-denied"));
}
foreach (var collection in collections)
{
if (collection.AppUserId != userId) continue;
collection.Promoted = dto.Promoted;
_unitOfWork.CollectionTagRepository.Update(collection);
}
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Promote/UnPromote multiple collections in one go
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("delete-multiple")]
public async Task<ActionResult> DeleteMultipleCollections(PromoteCollectionsDto dto)
{
// This needs to take into account owner as I can select other users cards
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
if (user == null) return Unauthorized();
user.Collections = user.Collections.Where(uc => !dto.CollectionIds.Contains(uc.Id)).ToList();
_unitOfWork.UserRepository.Update(user);
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Adds multiple series to a collection. If tag id is 0, this will create a new tag.
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-for-series")] [HttpPost("update-for-series")]
public async Task<ActionResult> AddToMultipleSeries(CollectionTagBulkAddDto dto) public async Task<ActionResult> AddToMultipleSeries(CollectionTagBulkAddDto dto)
{ {
// Create a new tag and save // Create a new tag and save
var tag = await _collectionService.GetTagOrCreate(dto.CollectionTagId, dto.CollectionTagTitle); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
if (user == null) return Unauthorized();
if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok(); AppUserCollection? tag;
if (dto.CollectionTagId == 0)
{
tag = new AppUserCollectionBuilder(dto.CollectionTagTitle).Build();
user.Collections.Add(tag);
}
else
{
// Validate tag doesn't exist
tag = user.Collections.FirstOrDefault(t => t.Id == dto.CollectionTagId);
}
if (tag == null)
{
return BadRequest(_localizationService.Translate(User.GetUserId(), "collection-doesnt-exists"));
}
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds.ToList());
foreach (var s in series)
{
if (tag.Items.Contains(s)) continue;
tag.Items.Add(s);
}
_unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync()) return Ok();
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
} }
@ -128,13 +195,12 @@ public class CollectionController : BaseApiController
/// </summary> /// </summary>
/// <param name="updateSeriesForTagDto"></param> /// <param name="updateSeriesForTagDto"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-series")] [HttpPost("update-series")]
public async Task<ActionResult> RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto) public async Task<ActionResult> RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto)
{ {
try try
{ {
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updateSeriesForTagDto.Tag.Id, CollectionTagIncludes.SeriesMetadata); var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(updateSeriesForTagDto.Tag.Id, CollectionIncludes.Series);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove)) if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove))
@ -149,24 +215,28 @@ public class CollectionController : BaseApiController
} }
/// <summary> /// <summary>
/// Removes the collection tag from all Series it was attached to /// Removes the collection tag from the user
/// </summary> /// </summary>
/// <param name="tagId"></param> /// <param name="tagId"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete] [HttpDelete]
public async Task<ActionResult> DeleteTag(int tagId) public async Task<ActionResult> DeleteTag(int tagId)
{ {
try try
{ {
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); if (user == null) return Unauthorized();
if (user.Collections.All(c => c.Id != tagId))
return BadRequest(await _localizationService.Translate(user.Id, "access-denied"));
if (await _collectionService.DeleteTag(tag)) if (await _collectionService.DeleteTag(tagId, user))
{
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-deleted")); return Ok(await _localizationService.Translate(User.GetUserId(), "collection-deleted"));
}
} }
catch (Exception) catch (Exception ex)
{ {
await _unitOfWork.RollbackAsync(); await _unitOfWork.RollbackAsync();
} }

View File

@ -111,7 +111,7 @@ public class ImageController : BaseApiController
} }
/// <summary> /// <summary>
/// Returns cover image for Collection Tag /// Returns cover image for Collection
/// </summary> /// </summary>
/// <param name="collectionTagId"></param> /// <param name="collectionTagId"></param>
/// <returns></returns> /// <returns></returns>
@ -121,6 +121,7 @@ public class ImageController : BaseApiController
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest(); if (userId == 0) return BadRequest();
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId)); var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
{ {

View File

@ -9,6 +9,7 @@ using API.Comparators;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Collection;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Filtering.v2; using API.DTOs.Filtering.v2;
@ -450,15 +451,13 @@ public class OpdsController : BaseApiController
var userId = await GetUser(apiKey); var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync())
: (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId));
var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(user.Id, true);
var (baseUrl, prefix) = await GetPrefix();
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix); var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix);
SetFeedId(feed, "collections"); SetFeedId(feed, "collections");
@ -467,12 +466,15 @@ public class OpdsController : BaseApiController
Id = tag.Id.ToString(), Id = tag.Id.ToString(),
Title = tag.Title, Title = tag.Title,
Summary = tag.Summary, Summary = tag.Summary,
Links = new List<FeedLink>() Links =
{ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"), CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"), $"{prefix}{apiKey}/collections/{tag.Id}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}") CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
} $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
]
})); }));
return CreateXmlResult(SerializeXml(feed)); return CreateXmlResult(SerializeXml(feed));
@ -489,20 +491,9 @@ public class OpdsController : BaseApiController
var (baseUrl, prefix) = await GetPrefix(); var (baseUrl, prefix) = await GetPrefix();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
IEnumerable <CollectionTagDto> tags; var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId);
if (isAdmin) if (tag == null || (tag.AppUserId != user.Id && !tag.Promoted))
{
tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
}
else
{
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId);
}
var tag = tags.SingleOrDefault(t => t.Id == collectionId);
if (tag == null)
{ {
return BadRequest("Collection does not exist or you don't have access"); return BadRequest("Collection does not exist or you don't have access");
} }

View File

@ -58,7 +58,7 @@ public class SearchController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList(); var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList();
if (!libraries.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted")); if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted"));
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);

View File

@ -147,7 +147,7 @@ public class UploadController : BaseApiController
try try
{ {
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id); var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(uploadFileDto.Id);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}");

View File

@ -40,6 +40,7 @@ public class WantToReadController : BaseApiController
/// <summary> /// <summary>
/// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2) /// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2)
/// </summary> /// </summary>
/// <remarks>This will be removed in v0.8.x</remarks>
/// <param name="userParams"></param> /// <param name="userParams"></param>
/// <param name="filterDto"></param> /// <param name="filterDto"></param>
/// <returns></returns> /// <returns></returns>

View File

@ -0,0 +1,39 @@
using System;
using API.Entities.Enums;
using API.Services.Plus;
namespace API.DTOs.Collection;
#nullable enable
public class AppUserCollectionDto
{
public int Id { get; init; }
public string Title { get; set; } = default!;
public string Summary { get; set; } = default!;
public bool Promoted { get; set; }
public AgeRating AgeRating { get; set; }
/// <summary>
/// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
/// </summary>
public string? CoverImage { get; set; } = string.Empty;
public bool CoverImageLocked { get; set; }
/// <summary>
/// Owner of the Collection
/// </summary>
public string? Owner { get; set; }
/// <summary>
/// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)
/// </summary>
public DateTime LastSyncUtc { get; set; }
/// <summary>
/// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote
/// </summary>
public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita;
/// <summary>
/// For Non-Kavita sourced collections, the url to sync from
/// </summary>
public string? SourceUrl { get; set; }
}

View File

@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace API.DTOs.Collection;
public class DeleteCollectionsDto
{
public IList<int> CollectionIds { get; set; }
}

View File

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.Collection;
public class PromoteCollectionsDto
{
public IList<int> CollectionIds { get; init; }
public bool Promoted { get; init; }
}

View File

@ -1,6 +1,7 @@
using System; using System;
namespace API.DTOs.ReadingLists; namespace API.DTOs.ReadingLists;
#nullable enable
public class ReadingListDto public class ReadingListDto
{ {
@ -15,7 +16,7 @@ public class ReadingListDto
/// <summary> /// <summary>
/// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
/// </summary> /// </summary>
public string CoverImage { get; set; } = string.Empty; public string? CoverImage { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Minimum Year the Reading List starts /// Minimum Year the Reading List starts
/// </summary> /// </summary>

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using API.DTOs.Collection;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Reader; using API.DTOs.Reader;
@ -13,7 +14,7 @@ public class SearchResultGroupDto
{ {
public IEnumerable<LibraryDto> Libraries { get; set; } = default!; public IEnumerable<LibraryDto> Libraries { get; set; } = default!;
public IEnumerable<SearchResultDto> Series { get; set; } = default!; public IEnumerable<SearchResultDto> Series { get; set; } = default!;
public IEnumerable<CollectionTagDto> Collections { get; set; } = default!; public IEnumerable<AppUserCollectionDto> Collections { get; set; } = default!;
public IEnumerable<ReadingListDto> ReadingLists { get; set; } = default!; public IEnumerable<ReadingListDto> ReadingLists { get; set; } = default!;
public IEnumerable<PersonDto> Persons { get; set; } = default!; public IEnumerable<PersonDto> Persons { get; set; } = default!;
public IEnumerable<GenreTagDto> Genres { get; set; } = default!; public IEnumerable<GenreTagDto> Genres { get; set; } = default!;

View File

@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using API.DTOs.CollectionTags;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.Entities.Enums; using API.Entities.Enums;
@ -10,11 +9,6 @@ public class SeriesMetadataDto
public int Id { get; set; } public int Id { get; set; }
public string Summary { get; set; } = string.Empty; public string Summary { get; set; } = string.Empty;
/// <summary>
/// Collections the Series belongs to
/// </summary>
public ICollection<CollectionTagDto> CollectionTags { get; set; } = new List<CollectionTagDto>();
/// <summary> /// <summary>
/// Genres for the Series /// Genres for the Series
/// </summary> /// </summary>

View File

@ -1,11 +1,6 @@
using System.Collections.Generic; namespace API.DTOs;
using System.ComponentModel.DataAnnotations;
using API.DTOs.CollectionTags;
namespace API.DTOs;
public class UpdateSeriesMetadataDto public class UpdateSeriesMetadataDto
{ {
public SeriesMetadataDto SeriesMetadata { get; set; } = default!; public SeriesMetadataDto SeriesMetadata { get; set; } = default!;
public ICollection<CollectionTagDto> CollectionTags { get; set; } = default!;
} }

View File

@ -36,6 +36,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<ServerSetting> ServerSetting { get; set; } = null!; public DbSet<ServerSetting> ServerSetting { get; set; } = null!;
public DbSet<AppUserPreferences> AppUserPreferences { get; set; } = null!; public DbSet<AppUserPreferences> AppUserPreferences { get; set; } = null!;
public DbSet<SeriesMetadata> SeriesMetadata { get; set; } = null!; public DbSet<SeriesMetadata> SeriesMetadata { get; set; } = null!;
[Obsolete]
public DbSet<CollectionTag> CollectionTag { get; set; } = null!; public DbSet<CollectionTag> CollectionTag { get; set; } = null!;
public DbSet<AppUserBookmark> AppUserBookmark { get; set; } = null!; public DbSet<AppUserBookmark> AppUserBookmark { get; set; } = null!;
public DbSet<ReadingList> ReadingList { get; set; } = null!; public DbSet<ReadingList> ReadingList { get; set; } = null!;
@ -64,6 +65,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!; public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!;
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!; public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
public DbSet<SeriesBlacklist> SeriesBlacklist { get; set; } = null!; public DbSet<SeriesBlacklist> SeriesBlacklist { get; set; } = null!;
public DbSet<AppUserCollection> AppUserCollection { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
@ -149,6 +151,10 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.WithOne(s => s.ExternalSeriesMetadata) .WithOne(s => s.ExternalSeriesMetadata)
.HasForeignKey<ExternalSeriesMetadata>(em => em.SeriesId) .HasForeignKey<ExternalSeriesMetadata>(em => em.SeriesId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.Entity<AppUserCollection>()
.Property(b => b.AgeRating)
.HasDefaultValue(AgeRating.Unknown);
} }
#nullable enable #nullable enable

View File

@ -0,0 +1,80 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Extensions.QueryExtensions;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.8.0 refactored User Collections
/// </summary>
public static class MigrateCollectionTagToUserCollections
{
public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger<Program> logger)
{
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateCollectionTagToUserCollections"))
{
return;
}
logger.LogCritical(
"Running MigrateCollectionTagToUserCollections migration - Please be patient, this may take some time. This is not an error");
// Find the first user that is an admin
var defaultAdmin = await unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections);
if (defaultAdmin == null)
{
await CompleteMigration(dataContext, logger);
return;
}
// For all collectionTags, move them over to said user
var existingCollections = await dataContext.CollectionTag
.OrderBy(c => c.NormalizedTitle)
.Includes(CollectionTagIncludes.SeriesMetadataWithSeries)
.ToListAsync();
foreach (var existingCollectionTag in existingCollections)
{
var collection = new AppUserCollection()
{
Title = existingCollectionTag.Title,
NormalizedTitle = existingCollectionTag.Title.Normalize(),
CoverImage = existingCollectionTag.CoverImage,
CoverImageLocked = existingCollectionTag.CoverImageLocked,
Promoted = existingCollectionTag.Promoted,
AgeRating = AgeRating.Unknown,
Summary = existingCollectionTag.Summary,
Items = existingCollectionTag.SeriesMetadatas.Select(s => s.Series).ToList()
};
collection.AgeRating = await unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(collection.Items.Select(s => s.Id));
defaultAdmin.Collections.Add(collection);
}
unitOfWork.UserRepository.Update(defaultAdmin);
await unitOfWork.CommitAsync();
await CompleteMigration(dataContext, logger);
}
private static async Task CompleteMigration(DataContext dataContext, ILogger<Program> logger)
{
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateCollectionTagToUserCollections",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await dataContext.SaveChangesAsync();
logger.LogCritical(
"Running MigrateCollectionTagToUserCollections migration - Completed. This is not an error");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,92 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class UserBasedCollections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppUserCollection",
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),
Summary = table.Column<string>(type: "TEXT", nullable: true),
Promoted = table.Column<bool>(type: "INTEGER", nullable: false),
CoverImage = table.Column<string>(type: "TEXT", nullable: true),
CoverImageLocked = table.Column<bool>(type: "INTEGER", nullable: false),
AgeRating = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
LastSyncUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
Source = table.Column<int>(type: "INTEGER", nullable: false),
SourceUrl = table.Column<string>(type: "TEXT", nullable: true),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserCollection", x => x.Id);
table.ForeignKey(
name: "FK_AppUserCollection_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AppUserCollectionSeries",
columns: table => new
{
CollectionsId = table.Column<int>(type: "INTEGER", nullable: false),
ItemsId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserCollectionSeries", x => new { x.CollectionsId, x.ItemsId });
table.ForeignKey(
name: "FK_AppUserCollectionSeries_AppUserCollection_CollectionsId",
column: x => x.CollectionsId,
principalTable: "AppUserCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AppUserCollectionSeries_Series_ItemsId",
column: x => x.ItemsId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserCollection_AppUserId",
table: "AppUserCollection",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserCollectionSeries_ItemsId",
table: "AppUserCollectionSeries",
column: "ItemsId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUserCollectionSeries");
migrationBuilder.DropTable(
name: "AppUserCollection");
}
}
}

View File

@ -189,6 +189,66 @@ namespace API.Data.Migrations
b.ToTable("AppUserBookmark"); b.ToTable("AppUserBookmark");
}); });
modelBuilder.Entity("API.Entities.AppUserCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AgeRating")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModifiedUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSyncUtc")
.HasColumnType("TEXT");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.Property<string>("SourceUrl")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserCollection");
});
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -1918,6 +1978,21 @@ namespace API.Data.Migrations
b.ToTable("Volume"); b.ToTable("Volume");
}); });
modelBuilder.Entity("AppUserCollectionSeries", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("INTEGER");
b.Property<int>("ItemsId")
.HasColumnType("INTEGER");
b.HasKey("CollectionsId", "ItemsId");
b.HasIndex("ItemsId");
b.ToTable("AppUserCollectionSeries");
});
modelBuilder.Entity("AppUserLibrary", b => modelBuilder.Entity("AppUserLibrary", b =>
{ {
b.Property<int>("AppUsersId") b.Property<int>("AppUsersId")
@ -2178,6 +2253,17 @@ namespace API.Data.Migrations
b.Navigation("AppUser"); b.Navigation("AppUser");
}); });
modelBuilder.Entity("API.Entities.AppUserCollection", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Collections")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
{ {
b.HasOne("API.Entities.AppUser", "AppUser") b.HasOne("API.Entities.AppUser", "AppUser")
@ -2626,6 +2712,21 @@ namespace API.Data.Migrations
b.Navigation("Series"); b.Navigation("Series");
}); });
modelBuilder.Entity("AppUserCollectionSeries", b =>
{
b.HasOne("API.Entities.AppUserCollection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Series", null)
.WithMany()
.HasForeignKey("ItemsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AppUserLibrary", b => modelBuilder.Entity("AppUserLibrary", b =>
{ {
b.HasOne("API.Entities.AppUser", null) b.HasOne("API.Entities.AppUser", null)
@ -2836,6 +2937,8 @@ namespace API.Data.Migrations
{ {
b.Navigation("Bookmarks"); b.Navigation("Bookmarks");
b.Navigation("Collections");
b.Navigation("DashboardStreams"); b.Navigation("DashboardStreams");
b.Navigation("Devices"); b.Navigation("Devices");

View File

@ -3,43 +3,61 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data.Misc; using API.Data.Misc;
using API.DTOs.Collection;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
using API.Extensions.QueryExtensions.Filtering;
using AutoMapper; using AutoMapper;
using AutoMapper.QueryableExtensions; using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories; namespace API.Data.Repositories;
#nullable enable
[Flags] [Flags]
public enum CollectionTagIncludes public enum CollectionTagIncludes
{ {
None = 1, None = 1,
SeriesMetadata = 2, SeriesMetadata = 2,
SeriesMetadataWithSeries = 4
}
[Flags]
public enum CollectionIncludes
{
None = 1,
Series = 2,
} }
public interface ICollectionTagRepository public interface ICollectionTagRepository
{ {
void Add(CollectionTag tag); void Remove(AppUserCollection tag);
void Remove(CollectionTag tag);
Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync();
Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery, int userId);
Task<string?> GetCoverImageAsync(int collectionTagId); Task<string?> GetCoverImageAsync(int collectionTagId);
Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync(int userId); Task<AppUserCollection?> GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None);
Task<CollectionTag?> GetTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.None); void Update(AppUserCollection tag);
void Update(CollectionTag tag); Task<int> RemoveCollectionsWithoutSeries();
Task<int> RemoveTagsWithoutSeries();
Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None); Task<IEnumerable<AppUserCollection>> GetAllCollectionsAsync(CollectionIncludes includes = CollectionIncludes.None);
/// <summary>
/// Returns all of the user's collections with the option of other user's promoted
/// </summary>
/// <param name="userId"></param>
/// <param name="includePromoted"></param>
/// <returns></returns>
Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosAsync(int userId, bool includePromoted = false);
Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false);
Task<IEnumerable<CollectionTag>> GetAllTagsByNamesAsync(IEnumerable<string> normalizedTitles,
CollectionTagIncludes includes = CollectionTagIncludes.None);
Task<IList<string>> GetAllCoverImagesAsync(); Task<IList<string>> GetAllCoverImagesAsync();
Task<bool> TagExists(string title); Task<bool> CollectionExists(string title, int userId);
Task<IList<CollectionTag>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task<IList<AppUserCollection>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<IList<string>> GetRandomCoverImagesAsync(int collectionId); Task<IList<string>> GetRandomCoverImagesAsync(int collectionId);
Task<IList<AppUserCollection>> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None);
Task UpdateCollectionAgeRating(AppUserCollection tag);
Task<IEnumerable<AppUserCollection>> GetCollectionsByIds(IEnumerable<int> tags, CollectionIncludes includes = CollectionIncludes.None);
} }
public class CollectionTagRepository : ICollectionTagRepository public class CollectionTagRepository : ICollectionTagRepository
{ {
@ -52,17 +70,12 @@ public class CollectionTagRepository : ICollectionTagRepository
_mapper = mapper; _mapper = mapper;
} }
public void Add(CollectionTag tag) public void Remove(AppUserCollection tag)
{ {
_context.CollectionTag.Add(tag); _context.AppUserCollection.Remove(tag);
} }
public void Remove(CollectionTag tag) public void Update(AppUserCollection tag)
{
_context.CollectionTag.Remove(tag);
}
public void Update(CollectionTag tag)
{ {
_context.Entry(tag).State = EntityState.Modified; _context.Entry(tag).State = EntityState.Modified;
} }
@ -70,38 +83,53 @@ public class CollectionTagRepository : ICollectionTagRepository
/// <summary> /// <summary>
/// Removes any collection tags without any series /// Removes any collection tags without any series
/// </summary> /// </summary>
public async Task<int> RemoveTagsWithoutSeries() public async Task<int> RemoveCollectionsWithoutSeries()
{ {
var tagsToDelete = await _context.CollectionTag var tagsToDelete = await _context.AppUserCollection
.Include(c => c.SeriesMetadatas) .Include(c => c.Items)
.Where(c => c.SeriesMetadatas.Count == 0) .Where(c => c.Items.Count == 0)
.AsSplitQuery() .AsSplitQuery()
.ToListAsync(); .ToListAsync();
_context.RemoveRange(tagsToDelete); _context.RemoveRange(tagsToDelete);
return await _context.SaveChangesAsync(); return await _context.SaveChangesAsync();
} }
public async Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None) public async Task<IEnumerable<AppUserCollection>> GetAllCollectionsAsync(CollectionIncludes includes = CollectionIncludes.None)
{ {
return await _context.CollectionTag return await _context.AppUserCollection
.OrderBy(c => c.NormalizedTitle) .OrderBy(c => c.NormalizedTitle)
.Includes(includes) .Includes(includes)
.ToListAsync(); .ToListAsync();
} }
public async Task<IEnumerable<CollectionTag>> GetAllTagsByNamesAsync(IEnumerable<string> normalizedTitles, CollectionTagIncludes includes = CollectionTagIncludes.None) public async Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosAsync(int userId, bool includePromoted = false)
{ {
return await _context.CollectionTag var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
.Where(c => normalizedTitles.Contains(c.NormalizedTitle)) return await _context.AppUserCollection
.OrderBy(c => c.NormalizedTitle) .Where(uc => uc.AppUserId == userId || (includePromoted && uc.Promoted))
.Includes(includes) .WhereIf(ageRating.AgeRating != AgeRating.NotApplicable, uc => uc.AgeRating <= ageRating.AgeRating)
.OrderBy(uc => uc.Title)
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false)
{
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.AppUserCollection
.Where(uc => uc.AppUserId == userId || (includePromoted && uc.Promoted))
.Where(uc => uc.Items.Any(s => s.Id == seriesId))
.WhereIf(ageRating.AgeRating != AgeRating.NotApplicable, uc => uc.AgeRating <= ageRating.AgeRating)
.OrderBy(uc => uc.Title)
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
} }
public async Task<string?> GetCoverImageAsync(int collectionTagId) public async Task<string?> GetCoverImageAsync(int collectionTagId)
{ {
return await _context.CollectionTag return await _context.AppUserCollection
.Where(c => c.Id == collectionTagId) .Where(c => c.Id == collectionTagId)
.Select(c => c.CoverImage) .Select(c => c.CoverImage)
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
@ -109,12 +137,13 @@ public class CollectionTagRepository : ICollectionTagRepository
public async Task<IList<string>> GetAllCoverImagesAsync() public async Task<IList<string>> GetAllCoverImagesAsync()
{ {
return (await _context.CollectionTag return await _context.AppUserCollection
.Select(t => t.CoverImage) .Select(t => t.CoverImage)
.Where(t => !string.IsNullOrEmpty(t)) .Where(t => !string.IsNullOrEmpty(t))
.ToListAsync())!; .ToListAsync();
} }
[Obsolete("use TagExists with userId")]
public async Task<bool> TagExists(string title) public async Task<bool> TagExists(string title)
{ {
var normalized = title.ToNormalized(); var normalized = title.ToNormalized();
@ -122,10 +151,24 @@ public class CollectionTagRepository : ICollectionTagRepository
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
} }
public async Task<IList<CollectionTag>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) /// <summary>
/// If any tag exists for that given user's collections
/// </summary>
/// <param name="title"></param>
/// <param name="userId"></param>
/// <returns></returns>
public async Task<bool> CollectionExists(string title, int userId)
{
var normalized = title.ToNormalized();
return await _context.AppUserCollection
.Where(uc => uc.AppUserId == userId)
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
}
public async Task<IList<AppUserCollection>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{ {
var extension = encodeFormat.GetExtension(); var extension = encodeFormat.GetExtension();
return await _context.CollectionTag return await _context.AppUserCollection
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension)) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync(); .ToListAsync();
} }
@ -139,12 +182,41 @@ public class CollectionTagRepository : ICollectionTagRepository
.Select(sm => sm.Series.CoverImage) .Select(sm => sm.Series.CoverImage)
.Where(t => !string.IsNullOrEmpty(t)) .Where(t => !string.IsNullOrEmpty(t))
.ToListAsync(); .ToListAsync();
return data return data
.OrderBy(_ => random.Next()) .OrderBy(_ => random.Next())
.Take(4) .Take(4)
.ToList(); .ToList();
} }
public async Task<IList<AppUserCollection>> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None)
{
return await _context.AppUserCollection
.Where(c => c.AppUserId == userId)
.Includes(includes)
.ToListAsync();
}
public async Task UpdateCollectionAgeRating(AppUserCollection tag)
{
var maxAgeRating = await _context.AppUserCollection
.Where(t => t.Id == tag.Id)
.SelectMany(uc => uc.Items.Select(s => s.Metadata))
.Select(sm => sm.AgeRating)
.MaxAsync();
tag.AgeRating = maxAgeRating;
await _context.SaveChangesAsync();
}
public async Task<IEnumerable<AppUserCollection>> GetCollectionsByIds(IEnumerable<int> tags, CollectionIncludes includes = CollectionIncludes.None)
{
return await _context.AppUserCollection
.Where(c => tags.Contains(c.Id))
.Includes(includes)
.AsSplitQuery()
.ToListAsync();
}
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync() public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
{ {
@ -168,9 +240,9 @@ public class CollectionTagRepository : ICollectionTagRepository
} }
public async Task<CollectionTag?> GetTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.None) public async Task<AppUserCollection?> GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None)
{ {
return await _context.CollectionTag return await _context.AppUserCollection
.Where(c => c.Id == tagId) .Where(c => c.Id == tagId)
.Includes(includes) .Includes(includes)
.AsSplitQuery() .AsSplitQuery()
@ -190,16 +262,12 @@ public class CollectionTagRepository : ICollectionTagRepository
.SingleAsync(); .SingleAsync();
} }
public async Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery, int userId) public async Task<IEnumerable<AppUserCollectionDto>> SearchTagDtosAsync(string searchQuery, int userId)
{ {
var userRating = await GetUserAgeRestriction(userId); var userRating = await GetUserAgeRestriction(userId);
return await _context.CollectionTag return await _context.AppUserCollection
.Where(s => EF.Functions.Like(s.Title!, $"%{searchQuery}%") .Search(searchQuery, userId, userRating)
|| EF.Functions.Like(s.NormalizedTitle!, $"%{searchQuery}%")) .ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.NormalizedTitle)
.AsNoTracking()
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
} }
} }

View File

@ -7,6 +7,7 @@ using API.Constants;
using API.Data.Misc; using API.Data.Misc;
using API.Data.Scanner; using API.Data.Scanner;
using API.DTOs; using API.DTOs;
using API.DTOs.Collection;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.DTOs.Dashboard; using API.DTOs.Dashboard;
using API.DTOs.Filtering; using API.DTOs.Filtering;
@ -141,7 +142,7 @@ public interface ISeriesRepository
MangaFormat format); MangaFormat format);
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId); Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId); Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
Task<AgeRating?> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds); Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
/// <summary> /// <summary>
/// This is only used for <see cref="MigrateUserProgressLibraryId"/> /// This is only used for <see cref="MigrateUserProgressLibraryId"/>
/// </summary> /// </summary>
@ -342,10 +343,7 @@ public class SeriesRepository : ISeriesRepository
return await _context.Library.GetUserLibraries(userId, queryContext).ToListAsync(); return await _context.Library.GetUserLibraries(userId, queryContext).ToListAsync();
} }
return new List<int>() return [libraryId];
{
libraryId
};
} }
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery) public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery)
@ -362,12 +360,9 @@ public class SeriesRepository : ISeriesRepository
.ToList(); .ToList();
result.Libraries = await _context.Library result.Libraries = await _context.Library
.Where(l => libraryIds.Contains(l.Id)) .Search(searchQuery, userId, libraryIds)
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
.IsRestricted(QueryContext.Search)
.AsSplitQuery()
.OrderBy(l => l.Name.ToLower())
.Take(maxRecords) .Take(maxRecords)
.OrderBy(l => l.Name.ToLower())
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider) .ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
@ -419,53 +414,33 @@ public class SeriesRepository : ISeriesRepository
result.ReadingLists = await _context.ReadingList result.ReadingLists = await _context.ReadingList
.Where(rl => rl.AppUserId == userId || rl.Promoted) .Search(searchQuery, userId, userRating)
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.AsSplitQuery()
.OrderBy(r => r.NormalizedTitle)
.Take(maxRecords) .Take(maxRecords)
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider) .ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
result.Collections = await _context.CollectionTag result.Collections = await _context.AppUserCollection
.Where(c => (EF.Functions.Like(c.Title, $"%{searchQuery}%")) .Search(searchQuery, userId, userRating)
|| (EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%")))
.Where(c => c.Promoted || isAdmin)
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.NormalizedTitle)
.AsSplitQuery()
.Take(maxRecords) .Take(maxRecords)
.OrderBy(c => c.NormalizedTitle) .OrderBy(c => c.NormalizedTitle)
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider) .ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
result.Persons = await _context.SeriesMetadata result.Persons = await _context.SeriesMetadata
.Where(sm => seriesIds.Contains(sm.SeriesId)) .SearchPeople(searchQuery, seriesIds)
.SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.OrderBy(p => p.NormalizedName)
.Take(maxRecords) .Take(maxRecords)
.OrderBy(t => t.NormalizedName)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider) .ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
result.Genres = await _context.SeriesMetadata result.Genres = await _context.SeriesMetadata
.Where(sm => seriesIds.Contains(sm.SeriesId)) .SearchGenres(searchQuery, seriesIds)
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.OrderBy(t => t.NormalizedTitle)
.Take(maxRecords) .Take(maxRecords)
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider) .ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
result.Tags = await _context.SeriesMetadata result.Tags = await _context.SeriesMetadata
.Where(sm => seriesIds.Contains(sm.SeriesId)) .SearchTags(searchQuery, seriesIds)
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.OrderBy(t => t.NormalizedTitle)
.Take(maxRecords) .Take(maxRecords)
.ProjectTo<TagDto>(_mapper.ConfigurationProvider) .ProjectTo<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
@ -740,6 +715,7 @@ public class SeriesRepository : ISeriesRepository
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series) public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
{ {
var userProgress = await _context.AppUserProgresses var userProgress = await _context.AppUserProgresses
@ -968,6 +944,20 @@ public class SeriesRepository : ISeriesRepository
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter,
out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter); out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter);
IList<int> collectionSeries = [];
if (hasCollectionTagFilter)
{
collectionSeries = await _context.AppUserCollection
.Where(uc => uc.Promoted || uc.AppUserId == userId)
.Where(uc => filter.CollectionTags.Contains(uc.Id))
.SelectMany(uc => uc.Items)
.RestrictAgainstAgeRestriction(userRating)
.Select(s => s.Id)
.Distinct()
.ToListAsync();
}
var query = _context.Series var query = _context.Series
.AsNoTracking() .AsNoTracking()
// This new style can handle any filterComparision coming from the user // This new style can handle any filterComparision coming from the user
@ -979,7 +969,7 @@ public class SeriesRepository : ISeriesRepository
.HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating) .HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating)
.HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus) .HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus)
.HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags) .HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags)
.HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags) .HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags, collectionSeries)
.HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres) .HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres)
.HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!) .HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
.HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0) .HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
@ -1045,6 +1035,8 @@ public class SeriesRepository : ISeriesRepository
.Select(u => u.CollapseSeriesRelationships) .Select(u => u.CollapseSeriesRelationships)
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
query ??= _context.Series query ??= _context.Series
.AsNoTracking(); .AsNoTracking();
@ -1062,6 +1054,9 @@ public class SeriesRepository : ISeriesRepository
query = ApplyWantToReadFilter(filter, query, userId); query = ApplyWantToReadFilter(filter, query, userId);
query = await ApplyCollectionFilter(filter, query, userId, userRating);
query = BuildFilterQuery(userId, filter, query); query = BuildFilterQuery(userId, filter, query);
@ -1078,6 +1073,50 @@ public class SeriesRepository : ISeriesRepository
.AsSplitQuery(), filter.LimitTo); .AsSplitQuery(), filter.LimitTo);
} }
private async Task<IQueryable<Series>> ApplyCollectionFilter(FilterV2Dto filter, IQueryable<Series> query, int userId, AgeRestriction userRating)
{
var collectionStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.CollectionTags);
if (collectionStmt == null) return query;
var value = (IList<int>) FilterFieldValueConverter.ConvertValue(collectionStmt.Field, collectionStmt.Value);
if (value.Count == 0)
{
return query;
}
var collectionSeries = await _context.AppUserCollection
.Where(uc => uc.Promoted || uc.AppUserId == userId)
.Where(uc => value.Contains(uc.Id))
.SelectMany(uc => uc.Items)
.RestrictAgainstAgeRestriction(userRating)
.Select(s => s.Id)
.Distinct()
.ToListAsync();
if (collectionStmt.Comparison != FilterComparison.MustContains)
return query.HasCollectionTags(true, collectionStmt.Comparison, value, collectionSeries);
var collectionSeriesTasks = value.Select(async collectionId =>
{
return await _context.AppUserCollection
.Where(uc => uc.Promoted || uc.AppUserId == userId)
.Where(uc => uc.Id == collectionId)
.SelectMany(uc => uc.Items)
.RestrictAgainstAgeRestriction(userRating)
.Select(s => s.Id)
.ToListAsync();
});
var collectionSeriesLists = await Task.WhenAll(collectionSeriesTasks);
// Find the common series among all collections
var commonSeries = collectionSeriesLists.Aggregate((common, next) => common.Intersect(next).ToList());
// Filter the original query based on the common series
return query.Where(s => commonSeries.Contains(s.Id));
}
private IQueryable<Series> ApplyWantToReadFilter(FilterV2Dto filter, IQueryable<Series> query, int userId) private IQueryable<Series> ApplyWantToReadFilter(FilterV2Dto filter, IQueryable<Series> query, int userId)
{ {
var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead); var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead);
@ -1175,7 +1214,6 @@ public class SeriesRepository : ISeriesRepository
FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value), FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value),
FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId), FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId),
FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value), FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
FilterField.CollectionTags => query.HasCollectionTags(true, statement.Comparison, (IList<int>) value),
FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value), FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value), FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value), FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value),
@ -1190,6 +1228,9 @@ public class SeriesRepository : ISeriesRepository
FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value), FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value), FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value), FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
FilterField.CollectionTags =>
// This is handled in the code before this as it's handled in a more general, combined manner
query,
FilterField.Libraries => FilterField.Libraries =>
// This is handled in the code before this as it's handled in a more general, combined manner // This is handled in the code before this as it's handled in a more general, combined manner
query, query,
@ -1241,7 +1282,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId) public async Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId)
{ {
var metadataDto = await _context.SeriesMetadata return await _context.SeriesMetadata
.Where(metadata => metadata.SeriesId == seriesId) .Where(metadata => metadata.SeriesId == seriesId)
.Include(m => m.Genres.OrderBy(g => g.NormalizedTitle)) .Include(m => m.Genres.OrderBy(g => g.NormalizedTitle))
.Include(m => m.Tags.OrderBy(g => g.NormalizedTitle)) .Include(m => m.Tags.OrderBy(g => g.NormalizedTitle))
@ -1250,42 +1291,20 @@ public class SeriesRepository : ISeriesRepository
.ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
.AsSplitQuery() .AsSplitQuery()
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
if (metadataDto != null)
{
metadataDto.CollectionTags = await _context.CollectionTag
.Include(t => t.SeriesMetadatas)
.Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId))
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.OrderBy(t => t.Title.ToLower())
.AsSplitQuery()
.ToListAsync();
}
return metadataDto;
} }
public async Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams) public async Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams)
{ {
var userLibraries = _context.Library var userLibraries = _context.Library.GetUserLibraries(userId);
.Include(l => l.AppUsers)
.Where(library => library.AppUsers.Any(user => user.Id == userId))
.AsSplitQuery()
.AsNoTracking()
.Select(library => library.Id)
.ToList();
var query = _context.CollectionTag var query = _context.AppUserCollection
.Where(s => s.Id == collectionId) .Where(s => s.Id == collectionId)
.Include(c => c.SeriesMetadatas) .Include(c => c.Items)
.ThenInclude(m => m.Series) .SelectMany(c => c.Items.Where(s => userLibraries.Contains(s.LibraryId)))
.SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId)))
.OrderBy(s => s.LibraryId) .OrderBy(s => s.LibraryId)
.ThenBy(s => s.SortName.ToLower()) .ThenBy(s => s.SortName.ToLower())
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery() .AsSplitQuery();
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize); return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
} }
@ -2072,18 +2091,20 @@ public class SeriesRepository : ISeriesRepository
} }
/// <summary> /// <summary>
/// Returns the highest Age Rating for a list of Series /// Returns the highest Age Rating for a list of Series. Defaults to <see cref="AgeRating.Unknown"/>
/// </summary> /// </summary>
/// <param name="seriesIds"></param> /// <param name="seriesIds"></param>
/// <returns></returns> /// <returns></returns>
public async Task<AgeRating?> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds) public async Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds)
{ {
return await _context.Series var ret = await _context.Series
.Where(s => seriesIds.Contains(s.Id)) .Where(s => seriesIds.Contains(s.Id))
.Include(s => s.Metadata) .Include(s => s.Metadata)
.Select(s => s.Metadata.AgeRating) .Select(s => s.Metadata.AgeRating)
.OrderBy(s => s) .OrderBy(s => s)
.LastOrDefaultAsync(); .LastOrDefaultAsync();
if (ret == null) return AgeRating.Unknown;
return ret;
} }
/// <summary> /// <summary>

View File

@ -38,7 +38,8 @@ public enum AppUserIncludes
SmartFilters = 1024, SmartFilters = 1024,
DashboardStreams = 2048, DashboardStreams = 2048,
SideNavStreams = 4096, SideNavStreams = 4096,
ExternalSources = 8192 // 2^13 ExternalSources = 8192,
Collections = 16384 // 2^14
} }
public interface IUserRepository public interface IUserRepository
@ -57,6 +58,7 @@ public interface IUserRepository
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true); Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
Task<IEnumerable<AppUser>> GetAdminUsersAsync(); Task<IEnumerable<AppUser>> GetAdminUsersAsync();
Task<bool> IsUserAdminAsync(AppUser? user); Task<bool> IsUserAdminAsync(AppUser? user);
Task<IList<string>> GetRoles(int userId);
Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId); Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId);
Task<IList<UserReviewDto>> GetUserRatingDtosForSeriesAsync(int seriesId, int userId); Task<IList<UserReviewDto>> GetUserRatingDtosForSeriesAsync(int seriesId, int userId);
Task<AppUserPreferences?> GetPreferencesAsync(string username); Task<AppUserPreferences?> GetPreferencesAsync(string username);
@ -78,7 +80,7 @@ public interface IUserRepository
Task<bool> HasAccessToSeries(int userId, int seriesId); Task<bool> HasAccessToSeries(int userId, int seriesId);
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None); Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None);
Task<AppUser?> GetUserByConfirmationToken(string token); Task<AppUser?> GetUserByConfirmationToken(string token);
Task<AppUser> GetDefaultAdminUser(); Task<AppUser> GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None);
Task<IEnumerable<AppUserRating>> GetSeriesWithRatings(int userId); Task<IEnumerable<AppUserRating>> GetSeriesWithRatings(int userId);
Task<IEnumerable<AppUserRating>> GetSeriesWithReviews(int userId); Task<IEnumerable<AppUserRating>> GetSeriesWithReviews(int userId);
Task<bool> HasHoldOnSeries(int userId, int seriesId); Task<bool> HasHoldOnSeries(int userId, int seriesId);
@ -298,11 +300,13 @@ public class UserRepository : IUserRepository
/// Returns the first admin account created /// Returns the first admin account created
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
public async Task<AppUser> GetDefaultAdminUser() public async Task<AppUser> GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None)
{ {
return (await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole)) return await _context.AppUser
.Includes(includes)
.Where(u => u.UserRoles.Any(r => r.Role.Name == PolicyConstants.AdminRole))
.OrderBy(u => u.Created) .OrderBy(u => u.Created)
.First(); .FirstAsync();
} }
public async Task<IEnumerable<AppUserRating>> GetSeriesWithRatings(int userId) public async Task<IEnumerable<AppUserRating>> GetSeriesWithRatings(int userId)
@ -482,7 +486,7 @@ public class UserRepository : IUserRepository
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync() public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{ {
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); return (await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole)).OrderBy(u => u.CreatedUtc);
} }
public async Task<bool> IsUserAdminAsync(AppUser? user) public async Task<bool> IsUserAdminAsync(AppUser? user)
@ -491,6 +495,14 @@ public class UserRepository : IUserRepository
return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
} }
public async Task<IList<string>> GetRoles(int userId)
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null || _userManager == null) return ArraySegment<string>.Empty; // userManager is null on Unit Tests only
return await _userManager.GetRolesAsync(user);
}
public async Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId) public async Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId)
{ {
return await _context.AppUserRating return await _context.AppUserRating

View File

@ -29,6 +29,10 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// </summary> /// </summary>
public ICollection<ReadingList> ReadingLists { get; set; } = null!; public ICollection<ReadingList> ReadingLists { get; set; } = null!;
/// <summary> /// <summary>
/// Collections associated with this user
/// </summary>
public ICollection<AppUserCollection> Collections { get; set; } = null!;
/// <summary>
/// A list of Series the user want's to read /// A list of Series the user want's to read
/// </summary> /// </summary>
public ICollection<AppUserWantToRead> WantToRead { get; set; } = null!; public ICollection<AppUserWantToRead> WantToRead { get; set; } = null!;

View File

@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Services.Plus;
namespace API.Entities;
/// <summary>
/// Represents a Collection of Series for a given User
/// </summary>
public class AppUserCollection : IEntityDate
{
public int Id { get; set; }
public required string Title { get; set; }
/// <summary>
/// A normalized string used to check if the collection already exists in the DB
/// </summary>
public required string NormalizedTitle { get; set; }
public string? Summary { get; set; }
/// <summary>
/// Reading lists that are promoted are only done by admins
/// </summary>
public bool Promoted { get; set; }
/// <summary>
/// Path to the (managed) image file
/// </summary>
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
public string? CoverImage { get; set; }
public bool CoverImageLocked { get; set; }
/// <summary>
/// The highest age rating from all Series within the collection
/// </summary>
public required AgeRating AgeRating { get; set; } = AgeRating.Unknown;
public ICollection<Series> Items { get; set; } = null!;
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
// Sync stuff for Kavita+
/// <summary>
/// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)
/// </summary>
public DateTime LastSyncUtc { get; set; }
/// <summary>
/// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote
/// </summary>
public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita;
/// <summary>
/// For Non-Kavita sourced collections, the url to sync from
/// </summary>
public string? SourceUrl { get; set; }
// Relationship
public AppUser AppUser { get; set; } = null!;
public int AppUserId { get; set; }
}

View File

@ -9,6 +9,7 @@ namespace API.Entities;
/// <summary> /// <summary>
/// Represents a user entered field that is used as a tagging and grouping mechanism /// Represents a user entered field that is used as a tagging and grouping mechanism
/// </summary> /// </summary>
[Obsolete("Use AppUserCollection instead")]
[Index(nameof(Id), nameof(Promoted), IsUnique = true)] [Index(nameof(Id), nameof(Promoted), IsUnique = true)]
public class CollectionTag public class CollectionTag
{ {

View File

@ -14,6 +14,7 @@ public class SeriesMetadata : IHasConcurrencyToken
public string Summary { get; set; } = string.Empty; public string Summary { get; set; } = string.Empty;
[Obsolete("Use AppUserCollection instead")]
public ICollection<CollectionTag> CollectionTags { get; set; } = new List<CollectionTag>(); public ICollection<CollectionTag> CollectionTags { get; set; } = new List<CollectionTag>();
public ICollection<Genre> Genres { get; set; } = new List<Genre>(); public ICollection<Genre> Genres { get; set; } = new List<Genre>();

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Interfaces; using API.Entities.Interfaces;
using API.Entities.Metadata; using API.Entities.Metadata;
using API.Extensions;
namespace API.Entities; namespace API.Entities;
@ -105,6 +106,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate
public ICollection<AppUserRating> Ratings { get; set; } = null!; public ICollection<AppUserRating> Ratings { get; set; } = null!;
public ICollection<AppUserProgress> Progress { get; set; } = null!; public ICollection<AppUserProgress> Progress { get; set; } = null!;
public ICollection<AppUserCollection> Collections { get; set; } = null!;
/// <summary> /// <summary>
/// Relations to other Series, like Sequels, Prequels, etc /// Relations to other Series, like Sequels, Prequels, etc
@ -114,6 +116,8 @@ public class Series : IEntityDate, IHasReadTimeEstimate
public ICollection<SeriesRelation> RelationOf { get; set; } = null!; public ICollection<SeriesRelation> RelationOf { get; set; } = null!;
// Relationships // Relationships
public List<Volume> Volumes { get; set; } = null!; public List<Volume> Volumes { get; set; } = null!;
public Library Library { get; set; } = null!; public Library Library { get; set; } = null!;
@ -131,4 +135,12 @@ public class Series : IEntityDate, IHasReadTimeEstimate
LastChapterAdded = DateTime.Now; LastChapterAdded = DateTime.Now;
LastChapterAddedUtc = DateTime.UtcNow; LastChapterAddedUtc = DateTime.UtcNow;
} }
public bool MatchesSeriesByName(string nameNormalized, string localizedNameNormalized)
{
return NormalizedName == nameNormalized ||
NormalizedLocalizedName == nameNormalized ||
NormalizedName == localizedNameNormalized ||
NormalizedLocalizedName == localizedNameNormalized;
}
} }

View File

@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Linq;
using API.Data.Misc;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Metadata;
using Microsoft.EntityFrameworkCore;
namespace API.Extensions.QueryExtensions.Filtering;
public static class SearchQueryableExtensions
{
public static IQueryable<AppUserCollection> Search(this IQueryable<AppUserCollection> queryable,
string searchQuery, int userId, AgeRestriction userRating)
{
return queryable
.Where(uc => uc.Promoted || uc.AppUserId == userId)
.Where(s => EF.Functions.Like(s.Title!, $"%{searchQuery}%")
|| EF.Functions.Like(s.NormalizedTitle!, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.NormalizedTitle);
}
public static IQueryable<ReadingList> Search(this IQueryable<ReadingList> queryable,
string searchQuery, int userId, AgeRestriction userRating)
{
return queryable
.Where(rl => rl.AppUserId == userId || rl.Promoted)
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.NormalizedTitle);
}
public static IQueryable<Library> Search(this IQueryable<Library> queryable,
string searchQuery, int userId, IEnumerable<int> libraryIds)
{
return queryable
.Where(l => libraryIds.Contains(l.Id))
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
.IsRestricted(QueryContext.Search)
.AsSplitQuery()
.OrderBy(l => l.Name.ToLower());
}
public static IQueryable<Person> SearchPeople(this IQueryable<SeriesMetadata> queryable,
string searchQuery, IEnumerable<int> seriesIds)
{
return queryable
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.OrderBy(p => p.NormalizedName);
}
public static IQueryable<Genre> SearchGenres(this IQueryable<SeriesMetadata> queryable,
string searchQuery, IEnumerable<int> seriesIds)
{
return queryable
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.Distinct()
.OrderBy(t => t.NormalizedTitle);
}
public static IQueryable<Tag> SearchTags(this IQueryable<SeriesMetadata> queryable,
string searchQuery, IEnumerable<int> seriesIds)
{
return queryable
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.OrderBy(t => t.NormalizedTitle);
}
}

View File

@ -551,25 +551,26 @@ public static class SeriesFilter
} }
public static IQueryable<Series> HasCollectionTags(this IQueryable<Series> queryable, bool condition, public static IQueryable<Series> HasCollectionTags(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> collectionTags) FilterComparison comparison, IList<int> collectionTags, IList<int> collectionSeries)
{ {
if (!condition || collectionTags.Count == 0) return queryable; if (!condition || collectionTags.Count == 0) return queryable;
switch (comparison) switch (comparison)
{ {
case FilterComparison.Equal: case FilterComparison.Equal:
case FilterComparison.Contains: case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id))); return queryable.Where(s => collectionSeries.Contains(s.Id));
case FilterComparison.NotContains: case FilterComparison.NotContains:
case FilterComparison.NotEqual: case FilterComparison.NotEqual:
return queryable.Where(s => !s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id))); return queryable.Where(s => !collectionSeries.Contains(s.Id));
case FilterComparison.MustContains: case FilterComparison.MustContains:
// Deconstruct and do a Union of a bunch of where statements since this doesn't translate // // Deconstruct and do a Union of a bunch of where statements since this doesn't translate
var queries = new List<IQueryable<Series>>() var queries = new List<IQueryable<Series>>()
{ {
queryable queryable
}; };
queries.AddRange(collectionTags.Select(gId => queryable.Where(s => s.Metadata.CollectionTags.Any(p => p.Id == gId)))); queries.AddRange(collectionSeries.Select(gId => queryable.Where(s => collectionSeries.Any(p => p == s.Id))));
return queries.Aggregate((q1, q2) => q1.Intersect(q2)); return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.GreaterThan: case FilterComparison.GreaterThan:

View File

@ -19,6 +19,23 @@ public static class IncludesExtensions
queryable = queryable.Include(c => c.SeriesMetadatas); queryable = queryable.Include(c => c.SeriesMetadatas);
} }
if (includes.HasFlag(CollectionTagIncludes.SeriesMetadataWithSeries))
{
queryable = queryable.Include(c => c.SeriesMetadatas).ThenInclude(s => s.Series);
}
return queryable.AsSplitQuery();
}
public static IQueryable<AppUserCollection> Includes(this IQueryable<AppUserCollection> queryable,
CollectionIncludes includes)
{
if (includes.HasFlag(CollectionIncludes.Series))
{
queryable = queryable.Include(c => c.Items);
}
return queryable.AsSplitQuery(); return queryable.AsSplitQuery();
} }
@ -206,6 +223,12 @@ public static class IncludesExtensions
query = query.Include(u => u.ExternalSources); query = query.Include(u => u.ExternalSources);
} }
if (includeFlags.HasFlag(AppUserIncludes.Collections))
{
query = query.Include(u => u.Collections)
.ThenInclude(c => c.Items);
}
return query.AsSplitQuery(); return query.AsSplitQuery();
} }

View File

@ -1,4 +1,5 @@
using System.Linq; using System;
using System.Linq;
using API.Data.Misc; using API.Data.Misc;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -24,6 +25,7 @@ public static class RestrictByAgeExtensions
return q; return q;
} }
[Obsolete]
public static IQueryable<CollectionTag> RestrictAgainstAgeRestriction(this IQueryable<CollectionTag> queryable, AgeRestriction restriction) public static IQueryable<CollectionTag> RestrictAgainstAgeRestriction(this IQueryable<CollectionTag> queryable, AgeRestriction restriction)
{ {
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
@ -38,6 +40,20 @@ public static class RestrictByAgeExtensions
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
} }
public static IQueryable<AppUserCollection> RestrictAgainstAgeRestriction(this IQueryable<AppUserCollection> queryable, AgeRestriction restriction)
{
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
if (restriction.IncludeUnknowns)
{
return queryable.Where(c => c.Items.All(sm =>
sm.Metadata.AgeRating <= restriction.AgeRating));
}
return queryable.Where(c => c.Items.All(sm =>
sm.Metadata.AgeRating <= restriction.AgeRating && sm.Metadata.AgeRating > AgeRating.Unknown));
}
public static IQueryable<Genre> RestrictAgainstAgeRestriction(this IQueryable<Genre> queryable, AgeRestriction restriction) public static IQueryable<Genre> RestrictAgainstAgeRestriction(this IQueryable<Genre> queryable, AgeRestriction restriction)
{ {
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;

View File

@ -3,6 +3,7 @@ using System.Linq;
using API.Data.Migrations; using API.Data.Migrations;
using API.DTOs; using API.DTOs;
using API.DTOs.Account; using API.DTOs.Account;
using API.DTOs.Collection;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.DTOs.Dashboard; using API.DTOs.Dashboard;
using API.DTOs.Device; using API.DTOs.Device;
@ -53,6 +54,8 @@ public class AutoMapperProfiles : Profile
CreateMap<Chapter, ChapterDto>(); CreateMap<Chapter, ChapterDto>();
CreateMap<Series, SeriesDto>(); CreateMap<Series, SeriesDto>();
CreateMap<CollectionTag, CollectionTagDto>(); CreateMap<CollectionTag, CollectionTagDto>();
CreateMap<AppUserCollection, AppUserCollectionDto>()
.ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName));
CreateMap<Person, PersonDto>(); CreateMap<Person, PersonDto>();
CreateMap<Genre, GenreTagDto>(); CreateMap<Genre, GenreTagDto>();
CreateMap<Tag, TagDto>(); CreateMap<Tag, TagDto>();
@ -141,10 +144,6 @@ public class AutoMapperProfiles : Profile
opt => opt =>
opt.MapFrom( opt.MapFrom(
src => src.Genres.OrderBy(p => p.NormalizedTitle))) src => src.Genres.OrderBy(p => p.NormalizedTitle)))
.ForMember(dest => dest.CollectionTags,
opt =>
opt.MapFrom(
src => src.CollectionTags.OrderBy(p => p.NormalizedTitle)))
.ForMember(dest => dest.Tags, .ForMember(dest => dest.Tags,
opt => opt =>
opt.MapFrom( opt.MapFrom(

View File

@ -0,0 +1,72 @@
using System.Collections.Generic;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services.Plus;
namespace API.Helpers.Builders;
public class AppUserCollectionBuilder : IEntityBuilder<AppUserCollection>
{
private readonly AppUserCollection _collection;
public AppUserCollection Build() => _collection;
public AppUserCollectionBuilder(string title, bool promoted = false)
{
title = title.Trim();
_collection = new AppUserCollection()
{
Id = 0,
NormalizedTitle = title.ToNormalized(),
Title = title,
Promoted = promoted,
Summary = string.Empty,
AgeRating = AgeRating.Unknown,
Source = ScrobbleProvider.Kavita,
Items = new List<Series>()
};
}
public AppUserCollectionBuilder WithSource(ScrobbleProvider provider)
{
_collection.Source = provider;
return this;
}
public AppUserCollectionBuilder WithSummary(string summary)
{
_collection.Summary = summary;
return this;
}
public AppUserCollectionBuilder WithIsPromoted(bool promoted)
{
_collection.Promoted = promoted;
return this;
}
public AppUserCollectionBuilder WithItem(Series series)
{
_collection.Items ??= new List<Series>();
_collection.Items.Add(series);
return this;
}
public AppUserCollectionBuilder WithItems(IEnumerable<Series> series)
{
_collection.Items ??= new List<Series>();
foreach (var s in series)
{
_collection.Items.Add(s);
}
return this;
}
public AppUserCollectionBuilder WithCoverImage(string cover)
{
_collection.CoverImage = cover;
return this;
}
}

View File

@ -1,57 +0,0 @@
using System.Collections.Generic;
using API.Entities;
using API.Entities.Metadata;
using API.Extensions;
namespace API.Helpers.Builders;
public class CollectionTagBuilder : IEntityBuilder<CollectionTag>
{
private readonly CollectionTag _collectionTag;
public CollectionTag Build() => _collectionTag;
public CollectionTagBuilder(string title, bool promoted = false)
{
title = title.Trim();
_collectionTag = new CollectionTag()
{
Id = 0,
NormalizedTitle = title.ToNormalized(),
Title = title,
Promoted = promoted,
Summary = string.Empty,
SeriesMetadatas = new List<SeriesMetadata>()
};
}
public CollectionTagBuilder WithId(int id)
{
_collectionTag.Id = id;
return this;
}
public CollectionTagBuilder WithSummary(string summary)
{
_collectionTag.Summary = summary;
return this;
}
public CollectionTagBuilder WithIsPromoted(bool promoted)
{
_collectionTag.Promoted = promoted;
return this;
}
public CollectionTagBuilder WithSeriesMetadata(SeriesMetadata seriesMetadata)
{
_collectionTag.SeriesMetadatas ??= new List<SeriesMetadata>();
_collectionTag.SeriesMetadatas.Add(seriesMetadata);
return this;
}
public CollectionTagBuilder WithCoverImage(string cover)
{
_collectionTag.CoverImage = cover;
return this;
}
}

View File

@ -1,13 +1,12 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants;
using API.Data; using API.Data;
using API.Data.Repositories; using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.Entities; using API.Entities;
using API.Entities.Metadata; using API.Extensions;
using API.Helpers.Builders; using API.Services.Plus;
using API.SignalR; using API.SignalR;
using Kavita.Common; using Kavita.Common;
@ -16,15 +15,9 @@ namespace API.Services;
public interface ICollectionTagService public interface ICollectionTagService
{ {
Task<bool> TagExistsByName(string name); Task<bool> DeleteTag(int tagId, AppUser user);
Task<bool> DeleteTag(CollectionTag tag); Task<bool> UpdateTag(AppUserCollectionDto dto, int userId);
Task<bool> UpdateTag(CollectionTagDto dto); Task<bool> RemoveTagFromSeries(AppUserCollection? tag, IEnumerable<int> seriesIds);
Task<bool> AddTagToSeries(CollectionTag? tag, IEnumerable<int> seriesIds);
Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds);
Task<CollectionTag> GetTagOrCreate(int tagId, string title);
void AddTagToSeriesMetadata(CollectionTag? tag, SeriesMetadata metadata);
CollectionTag CreateTag(string title);
Task<bool> RemoveTagsWithoutSeries();
} }
@ -39,37 +32,44 @@ public class CollectionTagService : ICollectionTagService
_eventHub = eventHub; _eventHub = eventHub;
} }
/// <summary> public async Task<bool> DeleteTag(int tagId, AppUser user)
/// Checks if a collection exists with the name
/// </summary>
/// <param name="name">If empty or null, will return true as that is invalid</param>
/// <returns></returns>
public async Task<bool> TagExistsByName(string name)
{ {
if (string.IsNullOrEmpty(name.Trim())) return true; var collectionTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(tagId);
return await _unitOfWork.CollectionTagRepository.TagExists(name); if (collectionTag == null) return true;
}
user.Collections.Remove(collectionTag);
if (!_unitOfWork.HasChanges()) return true;
public async Task<bool> DeleteTag(CollectionTag tag)
{
_unitOfWork.CollectionTagRepository.Remove(tag);
return await _unitOfWork.CommitAsync(); return await _unitOfWork.CommitAsync();
} }
public async Task<bool> UpdateTag(CollectionTagDto dto)
public async Task<bool> UpdateTag(AppUserCollectionDto dto, int userId)
{ {
var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(dto.Id); var existingTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(dto.Id);
if (existingTag == null) throw new KavitaException("collection-doesnt-exist"); if (existingTag == null) throw new KavitaException("collection-doesnt-exist");
if (existingTag.AppUserId != userId) throw new KavitaException("access-denied");
var title = dto.Title.Trim(); var title = dto.Title.Trim();
if (string.IsNullOrEmpty(title)) throw new KavitaException("collection-tag-title-required"); if (string.IsNullOrEmpty(title)) throw new KavitaException("collection-tag-title-required");
if (!title.Equals(existingTag.Title) && await TagExistsByName(dto.Title))
// Ensure the title doesn't exist on the user's account already
if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, userId))
throw new KavitaException("collection-tag-duplicate"); throw new KavitaException("collection-tag-duplicate");
existingTag.SeriesMetadatas ??= new List<SeriesMetadata>(); existingTag.Items ??= new List<Series>();
existingTag.Title = title; if (existingTag.Source == ScrobbleProvider.Kavita)
existingTag.NormalizedTitle = Tasks.Scanner.Parser.Parser.Normalize(dto.Title); {
existingTag.Promoted = dto.Promoted; existingTag.Title = title;
existingTag.NormalizedTitle = dto.Title.ToNormalized();
}
var roles = await _unitOfWork.UserRepository.GetRoles(userId);
if (roles.Contains(PolicyConstants.AdminRole) || roles.Contains(PolicyConstants.PromoteRole))
{
existingTag.Promoted = dto.Promoted;
}
existingTag.CoverImageLocked = dto.CoverImageLocked; existingTag.CoverImageLocked = dto.CoverImageLocked;
_unitOfWork.CollectionTagRepository.Update(existingTag); _unitOfWork.CollectionTagRepository.Update(existingTag);
@ -96,89 +96,31 @@ public class CollectionTagService : ICollectionTagService
} }
/// <summary> /// <summary>
/// Adds a set of Series to a Collection /// Removes series from Collection tag. Will recalculate max age rating.
/// </summary> /// </summary>
/// <param name="tag">A full Tag</param> /// <param name="tag"></param>
/// <param name="seriesIds"></param> /// <param name="seriesIds"></param>
/// <returns></returns> /// <returns></returns>
public async Task<bool> AddTagToSeries(CollectionTag? tag, IEnumerable<int> seriesIds) public async Task<bool> RemoveTagFromSeries(AppUserCollection? tag, IEnumerable<int> seriesIds)
{ {
if (tag == null) return false; if (tag == null) return false;
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(seriesIds);
foreach (var metadata in metadatas)
{
AddTagToSeriesMetadata(tag, metadata);
}
if (!_unitOfWork.HasChanges()) return true; tag.Items ??= new List<Series>();
return await _unitOfWork.CommitAsync(); tag.Items = tag.Items.Where(s => !seriesIds.Contains(s.Id)).ToList();
}
/// <summary> if (tag.Items.Count == 0)
/// Adds a collection tag to a SeriesMetadata
/// </summary>
/// <remarks>Does not commit</remarks>
/// <param name="tag"></param>
/// <param name="metadata"></param>
/// <returns></returns>
public void AddTagToSeriesMetadata(CollectionTag? tag, SeriesMetadata metadata)
{
if (tag == null) return;
metadata.CollectionTags ??= new List<CollectionTag>();
if (metadata.CollectionTags.Any(t => t.NormalizedTitle.Equals(tag.NormalizedTitle, StringComparison.InvariantCulture))) return;
metadata.CollectionTags.Add(tag);
if (metadata.Id != 0)
{
_unitOfWork.SeriesMetadataRepository.Update(metadata);
}
}
public async Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds)
{
if (tag == null) return false;
tag.SeriesMetadatas ??= new List<SeriesMetadata>();
foreach (var seriesIdToRemove in seriesIds)
{
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));
}
if (tag.SeriesMetadatas.Count == 0)
{ {
_unitOfWork.CollectionTagRepository.Remove(tag); _unitOfWork.CollectionTagRepository.Remove(tag);
} }
if (!_unitOfWork.HasChanges()) return true; if (!_unitOfWork.HasChanges()) return true;
return await _unitOfWork.CommitAsync(); var result = await _unitOfWork.CommitAsync();
} if (tag.Items.Count > 0)
{
await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(tag);
}
/// <summary> return result;
/// Tries to fetch the full tag, else returns a new tag. Adds to tracking but does not commit
/// </summary>
/// <param name="tagId"></param>
/// <param name="title"></param>
/// <returns></returns>
public async Task<CollectionTag> GetTagOrCreate(int tagId, string title)
{
return await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata) ?? CreateTag(title);
}
/// <summary>
/// This just creates the entity and adds to tracking. Use <see cref="GetTagOrCreate"/> for checks of duplication.
/// </summary>
/// <param name="title"></param>
/// <returns></returns>
public CollectionTag CreateTag(string title)
{
var tag = new CollectionTagBuilder(title).Build();
_unitOfWork.CollectionTagRepository.Add(tag);
return tag;
}
public async Task<bool> RemoveTagsWithoutSeries()
{
return await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries() > 0;
} }
} }

View File

@ -278,7 +278,7 @@ public class MetadataService : IMetadataService
await _unitOfWork.TagRepository.RemoveAllTagNoLongerAssociated(); await _unitOfWork.TagRepository.RemoveAllTagNoLongerAssociated();
await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated();
await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated();
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries();
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
} }

View File

@ -115,12 +115,6 @@ public class SeriesService : ISeriesService
if (series == null) return false; if (series == null) return false;
series.Metadata ??= new SeriesMetadataBuilder() series.Metadata ??= new SeriesMetadataBuilder()
.WithCollectionTags(updateSeriesMetadataDto.CollectionTags.Select(dto =>
new CollectionTagBuilder(dto.Title)
.WithId(dto.Id)
.WithSummary(dto.Summary)
.WithIsPromoted(dto.Promoted)
.Build()).ToList())
.Build(); .Build();
if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating) if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating)
@ -163,28 +157,16 @@ public class SeriesService : ISeriesService
series.Metadata.WebLinks = string.Empty; series.Metadata.WebLinks = string.Empty;
} else } else
{ {
series.Metadata.WebLinks = string.Join(",", updateSeriesMetadataDto.SeriesMetadata?.WebLinks series.Metadata.WebLinks = string.Join(',', updateSeriesMetadataDto.SeriesMetadata?.WebLinks
.Split(",") .Split(',')
.Where(s => !string.IsNullOrEmpty(s)) .Where(s => !string.IsNullOrEmpty(s))
.Select(s => s.Trim())! .Select(s => s.Trim())!
); );
} }
if (updateSeriesMetadataDto.CollectionTags.Count > 0)
{
var allCollectionTags = (await _unitOfWork.CollectionTagRepository
.GetAllTagsByNamesAsync(updateSeriesMetadataDto.CollectionTags.Select(t => Parser.Normalize(t.Title)))).ToList();
series.Metadata.CollectionTags ??= new List<CollectionTag>();
UpdateCollectionsList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, tag =>
{
series.Metadata.CollectionTags.Add(tag);
});
}
if (updateSeriesMetadataDto.SeriesMetadata?.Genres != null && if (updateSeriesMetadataDto.SeriesMetadata?.Genres != null &&
updateSeriesMetadataDto.SeriesMetadata.Genres.Any()) updateSeriesMetadataDto.SeriesMetadata.Genres.Count != 0)
{ {
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(updateSeriesMetadataDto.SeriesMetadata.Genres.Select(t => Parser.Normalize(t.Title)))).ToList(); var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(updateSeriesMetadataDto.SeriesMetadata.Genres.Select(t => Parser.Normalize(t.Title)))).ToList();
series.Metadata.Genres ??= new List<Genre>(); series.Metadata.Genres ??= new List<Genre>();
@ -320,12 +302,6 @@ public class SeriesService : ISeriesService
_logger.LogError(ex, "There was an issue cleaning up DB entries. This may happen if Komf is spamming updates. Nightly cleanup will work"); _logger.LogError(ex, "There was an issue cleaning up DB entries. This may happen if Komf is spamming updates. Nightly cleanup will work");
} }
if (updateSeriesMetadataDto.CollectionTags == null) return true;
foreach (var tag in updateSeriesMetadataDto.CollectionTags)
{
await _eventHub.SendMessageAsync(MessageFactory.SeriesAddedToCollection,
MessageFactory.SeriesAddedToCollectionEvent(tag.Id, seriesId), false);
}
return true; return true;
} }
catch (Exception ex) catch (Exception ex)
@ -337,46 +313,6 @@ public class SeriesService : ISeriesService
return false; return false;
} }
private static void UpdateCollectionsList(ICollection<CollectionTagDto>? tags, Series series, IReadOnlyCollection<CollectionTag> allTags,
Action<CollectionTag> handleAdd)
{
// TODO: Move UpdateCollectionsList to a helper so we can easily test
if (tags == null) return;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.CollectionTags.ToList();
foreach (var existing in existingTags)
{
if (tags.SingleOrDefault(t => t.Id == existing.Id) == null)
{
// Remove tag
series.Metadata.CollectionTags.Remove(existing);
}
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tag in tags)
{
var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title);
if (existingTag != null)
{
if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title))
{
handleAdd(existingTag);
}
}
else
{
// Add new tag
handleAdd(new CollectionTagBuilder(tag.Title)
.WithId(tag.Id)
.WithSummary(tag.Summary)
.WithIsPromoted(tag.Promoted)
.Build());
}
}
}
/// <summary> /// <summary>
/// ///
/// </summary> /// </summary>
@ -461,7 +397,7 @@ public class SeriesService : ISeriesService
} }
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries();
_taskScheduler.CleanupChapters(allChapterIds.ToArray()); _taskScheduler.CleanupChapters(allChapterIds.ToArray());
return true; return true;
} }

View File

@ -107,7 +107,7 @@ public class CleanupService : ICleanupService
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated();
await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated();
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries();
await _unitOfWork.ReadingListRepository.RemoveReadingListsWithoutSeries(); await _unitOfWork.ReadingListRepository.RemoveReadingListsWithoutSeries();
} }

View File

@ -467,14 +467,13 @@ public class ParseScannedFiles
} }
chapters = infos
.OrderByNatural(info => info.Chapters)
.ToList();
// If everything is a special but we don't have any SpecialIndex, then order naturally and use 0, 1, 2 // If everything is a special but we don't have any SpecialIndex, then order naturally and use 0, 1, 2
if (specialTreatment) if (specialTreatment)
{ {
chapters = infos
.OrderByNatural(info => Parser.Parser.RemoveExtensionIfSupported(info.Filename)!)
.ToList();
foreach (var chapter in chapters) foreach (var chapter in chapters)
{ {
chapter.IssueOrder = counter; chapter.IssueOrder = counter;
@ -483,6 +482,9 @@ public class ParseScannedFiles
return; return;
} }
chapters = infos
.OrderByNatural(info => info.Chapters)
.ToList();
counter = 0f; counter = 0f;
var prevIssue = string.Empty; var prevIssue = string.Empty;

View File

@ -6,6 +6,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.Metadata; using API.Data.Metadata;
using API.Data.Repositories;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;
@ -371,12 +372,26 @@ public class ProcessSeries : IProcessSeries
if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections) if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections)
{ {
// Get the default admin to associate these tags to
var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections);
if (defaultAdmin == null) return;
_logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name); _logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name);
foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{ {
var t = await _tagManagerService.GetCollectionTag(collection); var t = await _tagManagerService.GetCollectionTag(collection, defaultAdmin);
if (t == null) continue; if (t.Item1 == null) continue;
_collectionTagService.AddTagToSeriesMetadata(t, series.Metadata);
var tag = t.Item1;
// Check if the Series is already on the tag
if (tag.Items.Any(s => s.MatchesSeriesByName(series.NormalizedName, series.NormalizedLocalizedName)))
{
continue;
}
tag.Items.Add(series);
await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(tag);
} }
} }

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -28,7 +29,7 @@ public interface ITagManagerService
Task<Genre?> GetGenre(string genre); Task<Genre?> GetGenre(string genre);
Task<Tag?> GetTag(string tag); Task<Tag?> GetTag(string tag);
Task<Person?> GetPerson(string name, PersonRole role); Task<Person?> GetPerson(string name, PersonRole role);
Task<CollectionTag?> GetCollectionTag(string name); Task<Tuple<AppUserCollection?, bool>> GetCollectionTag(string? tag, AppUser userWithCollections);
} }
/// <summary> /// <summary>
@ -41,7 +42,7 @@ public class TagManagerService : ITagManagerService
private Dictionary<string, Genre> _genres; private Dictionary<string, Genre> _genres;
private Dictionary<string, Tag> _tags; private Dictionary<string, Tag> _tags;
private Dictionary<string, Person> _people; private Dictionary<string, Person> _people;
private Dictionary<string, CollectionTag> _collectionTags; private Dictionary<string, AppUserCollection> _collectionTags;
private readonly SemaphoreSlim _genreSemaphore = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _genreSemaphore = new SemaphoreSlim(1, 1);
private readonly SemaphoreSlim _tagSemaphore = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _tagSemaphore = new SemaphoreSlim(1, 1);
@ -57,10 +58,10 @@ public class TagManagerService : ITagManagerService
public void Reset() public void Reset()
{ {
_genres = new Dictionary<string, Genre>(); _genres = [];
_tags = new Dictionary<string, Tag>(); _tags = [];
_people = new Dictionary<string, Person>(); _people = [];
_collectionTags = new Dictionary<string, CollectionTag>(); _collectionTags = [];
} }
public async Task Prime() public async Task Prime()
@ -71,7 +72,8 @@ public class TagManagerService : ITagManagerService
.GroupBy(GetPersonKey) .GroupBy(GetPersonKey)
.Select(g => g.First()) .Select(g => g.First())
.ToDictionary(GetPersonKey); .ToDictionary(GetPersonKey);
_collectionTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync(CollectionTagIncludes.SeriesMetadata)) var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser()!;
_collectionTags = (await _unitOfWork.CollectionTagRepository.GetCollectionsForUserAsync(defaultAdmin.Id, CollectionIncludes.Series))
.ToDictionary(t => t.NormalizedTitle); .ToDictionary(t => t.NormalizedTitle);
} }
@ -183,28 +185,30 @@ public class TagManagerService : ITagManagerService
/// </summary> /// </summary>
/// <param name="tag"></param> /// <param name="tag"></param>
/// <returns></returns> /// <returns></returns>
public async Task<CollectionTag?> GetCollectionTag(string tag) public async Task<Tuple<AppUserCollection?, bool>> GetCollectionTag(string? tag, AppUser userWithCollections)
{ {
if (string.IsNullOrEmpty(tag)) return null; if (string.IsNullOrEmpty(tag)) return Tuple.Create<AppUserCollection?, bool>(null, false);
await _collectionTagSemaphore.WaitAsync(); await _collectionTagSemaphore.WaitAsync();
AppUserCollection? result;
try try
{ {
if (_collectionTags.TryGetValue(tag.ToNormalized(), out var result)) if (_collectionTags.TryGetValue(tag.ToNormalized(), out result))
{ {
return result; return Tuple.Create<AppUserCollection?, bool>(result, false);
} }
// We need to create a new Genre // We need to create a new Genre
result = new CollectionTagBuilder(tag).Build(); result = new AppUserCollectionBuilder(tag).Build();
_unitOfWork.CollectionTagRepository.Add(result); userWithCollections.Collections.Add(result);
_unitOfWork.UserRepository.Update(userWithCollections);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
_collectionTags.Add(result.NormalizedTitle, result); _collectionTags.Add(result.NormalizedTitle, result);
return result;
} }
finally finally
{ {
_collectionTagSemaphore.Release(); _collectionTagSemaphore.Release();
} }
return Tuple.Create<AppUserCollection?, bool>(result, true);
} }
} }

View File

@ -134,7 +134,7 @@ public class StatsService : IStatsService
HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(), HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(),
NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(), NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(),
NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count(), NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()).Count(),
NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(), NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(),
OPDSEnabled = serverSettings.EnableOpds, OPDSEnabled = serverSettings.EnableOpds,
NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Count(), NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Count(),

View File

@ -261,6 +261,7 @@ public class Startup
await MigrateChapterFields.Migrate(dataContext, unitOfWork, logger); await MigrateChapterFields.Migrate(dataContext, unitOfWork, logger);
await MigrateChapterRange.Migrate(dataContext, unitOfWork, logger); await MigrateChapterRange.Migrate(dataContext, unitOfWork, logger);
await MigrateMangaFilePath.Migrate(dataContext, logger); await MigrateMangaFilePath.Migrate(dataContext, logger);
await MigrateCollectionTagToUserCollections.Migrate(dataContext, unitOfWork, logger);
// Update the version in the DB after all migrations are run // Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);

View File

@ -1,11 +1,33 @@
export interface CollectionTag { import {ScrobbleProvider} from "../_services/scrobbling.service";
id: number; import {AgeRating} from "./metadata/age-rating";
title: string;
promoted: boolean; // Deprecated in v0.8, replaced with UserCollection
/** // export interface CollectionTag {
* This is used as a placeholder to store the coverImage url. The backend does not use this or send it. // id: number;
*/ // title: string;
coverImage: string; // promoted: boolean;
coverImageLocked: boolean; // /**
summary: string; // * This is used as a placeholder to store the coverImage url. The backend does not use this or send it.
} // */
// coverImage: string;
// coverImageLocked: boolean;
// summary: string;
// }
export interface UserCollection {
id: number;
title: string;
promoted: boolean;
/**
* This is used as a placeholder to store the coverImage url. The backend does not use this or send it.
*/
coverImage: string;
coverImageLocked: boolean;
summary: string;
lastSyncUtc: string;
owner: string;
source: ScrobbleProvider;
sourceUrl: string | null;
ageRating: AgeRating;
}

View File

@ -1,4 +1,3 @@
import { CollectionTag } from "../collection-tag";
import { Genre } from "./genre"; import { Genre } from "./genre";
import { AgeRating } from "./age-rating"; import { AgeRating } from "./age-rating";
import { PublicationStatus } from "./publication-status"; import { PublicationStatus } from "./publication-status";
@ -12,7 +11,6 @@ export interface SeriesMetadata {
totalCount: number; totalCount: number;
maxCount: number; maxCount: number;
collectionTags: Array<CollectionTag>;
genres: Array<Genre>; genres: Array<Genre>;
tags: Array<Tag>; tags: Array<Tag>;
writers: Array<Person>; writers: Array<Person>;

View File

@ -13,15 +13,15 @@ export class MangaFormatIconPipe implements PipeTransform {
transform(format: MangaFormat): string { transform(format: MangaFormat): string {
switch (format) { switch (format) {
case MangaFormat.EPUB: case MangaFormat.EPUB:
return 'fa-book'; return 'fa fa-book';
case MangaFormat.ARCHIVE: case MangaFormat.ARCHIVE:
return 'fa-file-archive'; return 'fa-solid fa-file-zipper';
case MangaFormat.IMAGE: case MangaFormat.IMAGE:
return 'fa-image'; return 'fa-solid fa-file-image';
case MangaFormat.PDF: case MangaFormat.PDF:
return 'fa-file-pdf'; return 'fa-solid fa-file-pdf';
case MangaFormat.UNKNOWN: case MangaFormat.UNKNOWN:
return 'fa-question'; return 'fa-solid fa-file-circle-question';
} }
} }

View File

@ -21,7 +21,9 @@ export enum Role {
Bookmark = 'Bookmark', Bookmark = 'Bookmark',
Download = 'Download', Download = 'Download',
ChangeRestriction = 'Change Restriction', ChangeRestriction = 'Change Restriction',
ReadOnly = 'Read Only' ReadOnly = 'Read Only',
Login = 'Login',
Promote = 'Promote',
} }
@Injectable({ @Injectable({
@ -96,6 +98,10 @@ export class AccountService {
return user && user.roles.includes(Role.ReadOnly); return user && user.roles.includes(Role.ReadOnly);
} }
hasPromoteRole(user: User) {
return user && user.roles.includes(Role.Promote) || user.roles.includes(Role.Admin);
}
getRoles() { getRoles() {
return this.httpClient.get<string[]>(this.baseUrl + 'account/roles'); return this.httpClient.get<string[]>(this.baseUrl + 'account/roles');
} }

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { map, Observable, shareReplay } from 'rxjs'; import { map, Observable, shareReplay } from 'rxjs';
import { Chapter } from '../_models/chapter'; import { Chapter } from '../_models/chapter';
import { CollectionTag } from '../_models/collection-tag'; import {UserCollection} from '../_models/collection-tag';
import { Device } from '../_models/device/device'; import { Device } from '../_models/device/device';
import { Library } from '../_models/library/library'; import { Library } from '../_models/library/library';
import { ReadingList } from '../_models/reading-list'; import { ReadingList } from '../_models/reading-list';
@ -10,6 +10,7 @@ import { Volume } from '../_models/volume';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { DeviceService } from './device.service'; import { DeviceService } from './device.service';
import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {User} from "../_models/user";
export enum Action { export enum Action {
Submenu = -1, Submenu = -1,
@ -97,12 +98,23 @@ export enum Action {
RemoveRuleGroup = 21, RemoveRuleGroup = 21,
MarkAsVisible = 22, MarkAsVisible = 22,
MarkAsInvisible = 23, MarkAsInvisible = 23,
/**
* Promotes the underlying item (Reading List, Collection)
*/
Promote = 24,
UnPromote = 25
} }
/**
* Callback for an action
*/
export type ActionCallback<T> = (action: ActionItem<T>, data: T) => void;
export type ActionAllowedCallback<T> = (action: ActionItem<T>) => boolean;
export interface ActionItem<T> { export interface ActionItem<T> {
title: string; title: string;
action: Action; action: Action;
callback: (action: ActionItem<T>, data: T) => void; callback: ActionCallback<T>;
requiresAdmin: boolean; requiresAdmin: boolean;
children: Array<ActionItem<T>>; children: Array<ActionItem<T>>;
/** /**
@ -132,7 +144,7 @@ export class ActionFactoryService {
chapterActions: Array<ActionItem<Chapter>> = []; chapterActions: Array<ActionItem<Chapter>> = [];
collectionTagActions: Array<ActionItem<CollectionTag>> = []; collectionTagActions: Array<ActionItem<UserCollection>> = [];
readingListActions: Array<ActionItem<ReadingList>> = []; readingListActions: Array<ActionItem<ReadingList>> = [];
@ -141,13 +153,11 @@ export class ActionFactoryService {
sideNavStreamActions: Array<ActionItem<SideNavStream>> = []; sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
isAdmin = false; isAdmin = false;
hasDownloadRole = false;
constructor(private accountService: AccountService, private deviceService: DeviceService) { constructor(private accountService: AccountService, private deviceService: DeviceService) {
this.accountService.currentUser$.subscribe((user) => { this.accountService.currentUser$.subscribe((user) => {
if (user) { if (user) {
this.isAdmin = this.accountService.hasAdminRole(user); this.isAdmin = this.accountService.hasAdminRole(user);
this.hasDownloadRole = this.accountService.hasDownloadRole(user);
} else { } else {
this._resetActions(); this._resetActions();
return; // If user is logged out, we don't need to do anything return; // If user is logged out, we don't need to do anything
@ -157,39 +167,39 @@ export class ActionFactoryService {
}); });
} }
getLibraryActions(callback: (action: ActionItem<Library>, library: Library) => void) { getLibraryActions(callback: ActionCallback<Library>) {
return this.applyCallbackToList(this.libraryActions, callback); return this.applyCallbackToList(this.libraryActions, callback);
} }
getSeriesActions(callback: (action: ActionItem<Series>, series: Series) => void) { getSeriesActions(callback: ActionCallback<Series>) {
return this.applyCallbackToList(this.seriesActions, callback); return this.applyCallbackToList(this.seriesActions, callback);
} }
getSideNavStreamActions(callback: (action: ActionItem<SideNavStream>, series: SideNavStream) => void) { getSideNavStreamActions(callback: ActionCallback<SideNavStream>) {
return this.applyCallbackToList(this.sideNavStreamActions, callback); return this.applyCallbackToList(this.sideNavStreamActions, callback);
} }
getVolumeActions(callback: (action: ActionItem<Volume>, volume: Volume) => void) { getVolumeActions(callback: ActionCallback<Volume>) {
return this.applyCallbackToList(this.volumeActions, callback); return this.applyCallbackToList(this.volumeActions, callback);
} }
getChapterActions(callback: (action: ActionItem<Chapter>, chapter: Chapter) => void) { getChapterActions(callback: ActionCallback<Chapter>) {
return this.applyCallbackToList(this.chapterActions, callback); return this.applyCallbackToList(this.chapterActions, callback);
} }
getCollectionTagActions(callback: (action: ActionItem<CollectionTag>, collectionTag: CollectionTag) => void) { getCollectionTagActions(callback: ActionCallback<UserCollection>) {
return this.applyCallbackToList(this.collectionTagActions, callback); return this.applyCallbackToList(this.collectionTagActions, callback);
} }
getReadingListActions(callback: (action: ActionItem<ReadingList>, readingList: ReadingList) => void) { getReadingListActions(callback: ActionCallback<ReadingList>) {
return this.applyCallbackToList(this.readingListActions, callback); return this.applyCallbackToList(this.readingListActions, callback);
} }
getBookmarkActions(callback: (action: ActionItem<Series>, series: Series) => void) { getBookmarkActions(callback: ActionCallback<Series>) {
return this.applyCallbackToList(this.bookmarkActions, callback); return this.applyCallbackToList(this.bookmarkActions, callback);
} }
getMetadataFilterActions(callback: (action: ActionItem<any>, data: any) => void) { getMetadataFilterActions(callback: ActionCallback<any>) {
const actions = [ const actions = [
{title: 'add-rule-group-and', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback}, {title: 'add-rule-group-and', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback},
{title: 'add-rule-group-or', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback}, {title: 'add-rule-group-or', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback},
@ -260,7 +270,7 @@ export class ActionFactoryService {
action: Action.Edit, action: Action.Edit,
title: 'edit', title: 'edit',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: true, requiresAdmin: false,
children: [], children: [],
}, },
{ {
@ -271,6 +281,20 @@ export class ActionFactoryService {
class: 'danger', class: 'danger',
children: [], children: [],
}, },
{
action: Action.Promote,
title: 'promote',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{
action: Action.UnPromote,
title: 'unpromote',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
]; ];
this.seriesActions = [ this.seriesActions = [
@ -326,7 +350,7 @@ export class ActionFactoryService {
action: Action.AddToCollection, action: Action.AddToCollection,
title: 'add-to-collection', title: 'add-to-collection',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: true, requiresAdmin: false,
children: [], children: [],
}, },
], ],

View File

@ -20,6 +20,8 @@ import { MemberService } from './member.service';
import { ReaderService } from './reader.service'; import { ReaderService } from './reader.service';
import { SeriesService } from './series.service'; import { SeriesService } from './series.service';
import {translate, TranslocoService} from "@ngneat/transloco"; import {translate, TranslocoService} from "@ngneat/transloco";
import {UserCollection} from "../_models/collection-tag";
import {CollectionTagService} from "./collection-tag.service";
export type LibraryActionCallback = (library: Partial<Library>) => void; export type LibraryActionCallback = (library: Partial<Library>) => void;
export type SeriesActionCallback = (series: Series) => void; export type SeriesActionCallback = (series: Series) => void;
@ -43,7 +45,8 @@ export class ActionService implements OnDestroy {
constructor(private libraryService: LibraryService, private seriesService: SeriesService, constructor(private libraryService: LibraryService, private seriesService: SeriesService,
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal, private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService) { } private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService,
private readonly collectionTagService: CollectionTagService) { }
ngOnDestroy() { ngOnDestroy() {
this.onDestroy.next(); this.onDestroy.next();
@ -380,6 +383,43 @@ export class ActionService implements OnDestroy {
}); });
} }
/**
* Mark all series as Unread.
* @param collections UserCollection, should have id, pagesRead populated
* @param promoted boolean, promoted state
* @param callback Optional callback to perform actions after API completes
*/
promoteMultipleCollections(collections: Array<UserCollection>, promoted: boolean, callback?: BooleanActionCallback) {
this.collectionTagService.promoteMultipleCollections(collections.map(v => v.id), promoted).pipe(take(1)).subscribe(() => {
if (promoted) {
this.toastr.success(translate('toasts.collections-promoted'));
} else {
this.toastr.success(translate('toasts.collections-unpromoted'));
}
if (callback) {
callback(true);
}
});
}
/**
* Deletes multiple collections
* @param collections UserCollection, should have id, pagesRead populated
* @param callback Optional callback to perform actions after API completes
*/
async deleteMultipleCollections(collections: Array<UserCollection>, callback?: BooleanActionCallback) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-collections'))) return;
this.collectionTagService.deleteMultipleCollections(collections.map(v => v.id)).pipe(take(1)).subscribe(() => {
this.toastr.success(translate('toasts.collections-deleted'));
if (callback) {
callback(true);
}
});
}
addMultipleToReadingList(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: BooleanActionCallback) { addMultipleToReadingList(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: BooleanActionCallback) {
if (this.readingListModalRef != null) { return; } if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });

View File

@ -1,11 +1,12 @@
import { HttpClient } from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import { map } from 'rxjs/operators'; import {environment} from 'src/environments/environment';
import { environment } from 'src/environments/environment'; import {UserCollection} from '../_models/collection-tag';
import { CollectionTag } from '../_models/collection-tag'; import {TextResonse} from '../_types/text-response';
import { TextResonse } from '../_types/text-response';
import { ImageService } from './image.service';
import {MalStack} from "../_models/collection/mal-stack"; import {MalStack} from "../_models/collection/mal-stack";
import {Action, ActionItem} from "./action-factory.service";
import {User} from "../_models/user";
import {AccountService} from "./account.service";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -14,24 +15,25 @@ export class CollectionTagService {
baseUrl = environment.apiUrl; baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient, private imageService: ImageService) { } constructor(private httpClient: HttpClient, private accountService: AccountService) { }
allTags() { allCollections(ownedOnly = false) {
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/'); return this.httpClient.get<UserCollection[]>(this.baseUrl + 'collection?ownedOnly=' + ownedOnly);
} }
search(query: string) { allCollectionsForSeries(seriesId: number, ownedOnly = false) {
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/search?queryString=' + encodeURIComponent(query)).pipe(map(tags => { return this.httpClient.get<UserCollection[]>(this.baseUrl + 'collection/all-series?ownedOnly=' + ownedOnly + '&seriesId=' + seriesId);
tags.forEach(s => s.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(s.id)));
return tags;
}));
} }
updateTag(tag: CollectionTag) { updateTag(tag: UserCollection) {
return this.httpClient.post(this.baseUrl + 'collection/update', tag, TextResonse); return this.httpClient.post(this.baseUrl + 'collection/update', tag, TextResonse);
} }
updateSeriesForTag(tag: CollectionTag, seriesIdsToRemove: Array<number>) { promoteMultipleCollections(tags: Array<number>, promoted: boolean) {
return this.httpClient.post(this.baseUrl + 'collection/promote-multiple', {collectionIds: tags, promoted}, TextResonse);
}
updateSeriesForTag(tag: UserCollection, seriesIdsToRemove: Array<number>) {
return this.httpClient.post(this.baseUrl + 'collection/update-series', {tag, seriesIdsToRemove}, TextResonse); return this.httpClient.post(this.baseUrl + 'collection/update-series', {tag, seriesIdsToRemove}, TextResonse);
} }
@ -47,7 +49,19 @@ export class CollectionTagService {
return this.httpClient.delete<string>(this.baseUrl + 'collection?tagId=' + tagId, TextResonse); return this.httpClient.delete<string>(this.baseUrl + 'collection?tagId=' + tagId, TextResonse);
} }
deleteMultipleCollections(tags: Array<number>) {
return this.httpClient.post(this.baseUrl + 'collection/delete-multiple', {collectionIds: tags}, TextResonse);
}
getMalStacks() { getMalStacks() {
return this.httpClient.get<Array<MalStack>>(this.baseUrl + 'collection/mal-stacks'); return this.httpClient.get<Array<MalStack>>(this.baseUrl + 'collection/mal-stacks');
} }
actionListFilter(action: ActionItem<UserCollection>, user: User) {
const canPromote = this.accountService.hasAdminRole(user) || this.accountService.hasPromoteRole(user);
const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote;
if (isPromotionAction) return canPromote;
return true;
}
} }

View File

@ -6,7 +6,7 @@ import { environment } from 'src/environments/environment';
import { UtilityService } from '../shared/_services/utility.service'; import { UtilityService } from '../shared/_services/utility.service';
import { Chapter } from '../_models/chapter'; import { Chapter } from '../_models/chapter';
import { ChapterMetadata } from '../_models/metadata/chapter-metadata'; import { ChapterMetadata } from '../_models/metadata/chapter-metadata';
import { CollectionTag } from '../_models/collection-tag'; import { UserCollection } from '../_models/collection-tag';
import { PaginatedResult } from '../_models/pagination'; import { PaginatedResult } from '../_models/pagination';
import { Series } from '../_models/series'; import { Series } from '../_models/series';
import { RelatedSeries } from '../_models/series-detail/related-series'; import { RelatedSeries } from '../_models/series-detail/related-series';
@ -162,16 +162,12 @@ 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);
items?.collectionTags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id));
return items;
}));
} }
updateMetadata(seriesMetadata: SeriesMetadata, collectionTags: CollectionTag[]) { updateMetadata(seriesMetadata: SeriesMetadata) {
const data = { const data = {
seriesMetadata, seriesMetadata,
collectionTags,
}; };
return this.httpClient.post(this.baseUrl + 'series/metadata', data, TextResonse); return this.httpClient.post(this.baseUrl + 'series/metadata', data, TextResonse);
} }

View File

@ -1,11 +1,20 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
EventEmitter,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap'; import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
import { take } from 'rxjs';
import { AccountService } from 'src/app/_services/account.service'; import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import {CommonModule} from "@angular/common"; import {CommonModule} from "@angular/common";
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Component({ @Component({
selector: 'app-card-actionables', selector: 'app-card-actionables',
@ -17,6 +26,10 @@ import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
}) })
export class CardActionablesComponent implements OnInit { export class CardActionablesComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly accountService = inject(AccountService);
private readonly destroyRef = inject(DestroyRef);
@Input() iconClass = 'fa-ellipsis-v'; @Input() iconClass = 'fa-ellipsis-v';
@Input() btnClass = ''; @Input() btnClass = '';
@Input() actions: ActionItem<any>[] = []; @Input() actions: ActionItem<any>[] = [];
@ -27,20 +40,22 @@ export class CardActionablesComponent implements OnInit {
isAdmin: boolean = false; isAdmin: boolean = false;
canDownload: boolean = false; canDownload: boolean = false;
canPromote: boolean = false;
submenu: {[key: string]: NgbDropdown} = {}; submenu: {[key: string]: NgbDropdown} = {};
constructor(private readonly cdRef: ChangeDetectorRef, private accountService: AccountService) { }
ngOnInit(): void { ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe((user) => { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
if (!user) return; if (!user) return;
this.isAdmin = this.accountService.hasAdminRole(user); this.isAdmin = this.accountService.hasAdminRole(user);
this.canDownload = this.accountService.hasDownloadRole(user); this.canDownload = this.accountService.hasDownloadRole(user);
this.canPromote = this.accountService.hasPromoteRole(user);
// We want to avoid an empty menu when user doesn't have access to anything // We want to avoid an empty menu when user doesn't have access to anything
if (!this.isAdmin && this.actions.filter(a => !a.requiresAdmin).length === 0) { if (!this.isAdmin && this.actions.filter(a => !a.requiresAdmin).length === 0) {
this.actions = []; this.actions = [];
} }
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
} }
@ -61,7 +76,10 @@ export class CardActionablesComponent implements OnInit {
willRenderAction(action: ActionItem<any>) { willRenderAction(action: ActionItem<any>) {
return (action.requiresAdmin && this.isAdmin) return (action.requiresAdmin && this.isAdmin)
|| (action.action === Action.Download && (this.canDownload || this.isAdmin)) || (action.action === Action.Download && (this.canDownload || this.isAdmin))
|| (!action.requiresAdmin && action.action !== Action.Download); || (!action.requiresAdmin && action.action !== Action.Download)
|| (action.action === Action.Promote && (this.canPromote || this.isAdmin))
|| (action.action === Action.UnPromote && (this.canPromote || this.isAdmin))
;
} }
shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) { shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) {

View File

@ -6,23 +6,37 @@
</div> </div>
<form style="width: 100%" [formGroup]="listForm"> <form style="width: 100%" [formGroup]="listForm">
<div class="modal-body"> <div class="modal-body">
<div class="mb-3" *ngIf="lists.length >= 5"> @if (lists.length >= 5) {
<label for="filter" class="form-label">{{t('filter-label')}}</label> <div class="mb-3">
<div class="input-group"> <label for="filter" class="form-label">{{t('filter-label')}}</label>
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input"> <div class="input-group">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button> <input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
</div> <button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
</div>
<ul class="list-group">
<li class="list-group-item clickable" tabindex="0" role="option" *ngFor="let collectionTag of lists | filter: filterList; let i = index; trackBy: collectionTitleTrackby" (click)="addToCollection(collectionTag)">
{{collectionTag.title}} <i class="fa fa-angle-double-up" *ngIf="collectionTag.promoted" [title]="t('promoted')"></i>
</li>
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">{{t('no-data')}}</li>
<li class="list-group-item" *ngIf="loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{t('loading')}}</span>
</div> </div>
</li> </div>
}
<ul class="list-group">
@for(collectionTag of lists | filter: filterList; let i = $index; track collectionTag.title) {
<li class="list-group-item clickable" tabindex="0" role="option" (click)="addToCollection(collectionTag)">
{{collectionTag.title}}
@if (collectionTag.promoted) {
<i class="fa fa-angle-double-up" [title]="t('promoted')"></i>
}
</li>
}
@if (lists.length === 0 && !loading) {
<li class="list-group-item">{{t('no-data')}}</li>
}
@if (loading) {
<li class="list-group-item">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{t('loading')}}</span>
</div>
</li>
}
</ul> </ul>
</div> </div>
<div class="modal-footer" style="justify-content: normal"> <div class="modal-footer" style="justify-content: normal">

View File

@ -13,7 +13,7 @@ import {
import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms'; import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms';
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap'; import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { CollectionTag } from 'src/app/_models/collection-tag'; import {UserCollection} from 'src/app/_models/collection-tag';
import { ReadingList } from 'src/app/_models/reading-list'; import { ReadingList } from 'src/app/_models/reading-list';
import { CollectionTagService } from 'src/app/_services/collection-tag.service'; import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import {CommonModule} from "@angular/common"; import {CommonModule} from "@angular/common";
@ -31,28 +31,25 @@ import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco
}) })
export class BulkAddToCollectionComponent implements OnInit, AfterViewInit { export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
private readonly modal = inject(NgbActiveModal);
private readonly collectionService = inject(CollectionTagService);
private readonly toastr = inject(ToastrService);
private readonly cdRef = inject(ChangeDetectorRef);
@Input({required: true}) title!: string; @Input({required: true}) title!: string;
/** /**
* Series Ids to add to Collection Tag * Series Ids to add to Collection Tag
*/ */
@Input() seriesIds: Array<number> = []; @Input() seriesIds: Array<number> = [];
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
/** /**
* All existing collections sorted by recent use date * All existing collections sorted by recent use date
*/ */
lists: Array<CollectionTag> = []; lists: Array<UserCollection> = [];
loading: boolean = false; loading: boolean = false;
listForm: FormGroup = new FormGroup({}); listForm: FormGroup = new FormGroup({});
collectionTitleTrackby = (index: number, item: CollectionTag) => `${item.title}`;
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
constructor(private modal: NgbActiveModal, private collectionService: CollectionTagService,
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void { ngOnInit(): void {
this.listForm.addControl('title', new FormControl(this.title, [])); this.listForm.addControl('title', new FormControl(this.title, []));
@ -60,7 +57,7 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
this.loading = true; this.loading = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.collectionService.allTags().subscribe(tags => { this.collectionService.allCollections(true).subscribe(tags => {
this.lists = tags; this.lists = tags;
this.loading = false; this.loading = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
@ -87,7 +84,7 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
}); });
} }
addToCollection(tag: CollectionTag) { addToCollection(tag: UserCollection) {
if (this.seriesIds.length === 0) return; if (this.seriesIds.length === 0) return;
this.collectionService.addByMultiple(tag.id, this.seriesIds, '').subscribe(() => { this.collectionService.addByMultiple(tag.id, this.seriesIds, '').subscribe(() => {

View File

@ -16,14 +16,16 @@
<label for="library-name" class="form-label">{{t('name-label')}}</label> <label for="library-name" class="form-label">{{t('name-label')}}</label>
<input id="library-name" class="form-control" formControlName="title" type="text" <input id="library-name" class="form-control" formControlName="title" type="text"
[class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched"> [class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="collectionTagForm.dirty || collectionTagForm.touched"> @if (collectionTagForm.dirty || collectionTagForm.touched) {
<div *ngIf="collectionTagForm.get('title')?.errors?.required"> <div id="inviteForm-validations" class="invalid-feedback">
{{t('required-field')}} @if (collectionTagForm.get('title')?.errors?.required) {
<div>{{t('required-field')}}</div>
}
@if (collectionTagForm.get('title')?.errors?.duplicateName) {
<div>{{t('name-validation')}}</div>
}
</div> </div>
<div *ngIf="collectionTagForm.get('title')?.errors?.duplicateName"> }
{{t('name-validation')}}
</div>
</div>
</div> </div>
<div class="col-md-3 col-sm-12 ms-2"> <div class="col-md-3 col-sm-12 ms-2">
<div class="form-check form-switch"> <div class="form-check form-switch">
@ -49,32 +51,46 @@
<li [ngbNavItem]="TabID.Series"> <li [ngbNavItem]="TabID.Series">
<a ngbNavLink>{{t(TabID.Series)}}</a> <a ngbNavLink>{{t(TabID.Series)}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="list-group" *ngIf="!isLoading"> @if (!isLoading) {
<h6>{{t('series-title')}}</h6> <div class="list-group">
<div class="form-check"> <form [formGroup]="formGroup">
<input id="selectall" type="checkbox" class="form-check-input" <div class="row g-0 mb-3">
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected"> <div class="col-md-12">
<label for="selectall" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label> <label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
</div> <div class="input-group">
<ul> <input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
<li class="list-group-item" *ngFor="let item of series; let i = index"> </div>
<div class="form-check"> </div>
<input id="series-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
</div> </div>
</li> </form>
</ul> <div class="form-check">
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0"> <input id="select-all" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
<ngb-pagination [ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
*ngIf="pagination.totalPages > 1" <label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
[(page)]="pagination.currentPage" </div>
[pageSize]="pagination.itemsPerPage" <ul>
(pageChange)="onPageChange($event)" @for (item of series | filter: filterList; let i = $index; track item.id) {
[rotate]="false" [ellipses]="false" [boundaryLinks]="true" <li class="list-group-item">
[collectionSize]="pagination.totalItems"></ngb-pagination> <div class="form-check">
<input id="series-{{i}}" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
<label for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
</div>
</li>
}
</ul>
@if (pagination && series.length !== 0 && pagination.totalPages > 1) {
<div class="d-flex justify-content-center">
<ngb-pagination
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
[collectionSize]="pagination.totalItems"></ngb-pagination>
</div>
}
</div> </div>
</div> }
</ng-template> </ng-template>
</li> </li>

View File

@ -1,12 +1,4 @@
import { import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
inject,
Input,
OnInit
} from '@angular/core';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms'; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
import { import {
NgbActiveModal, NgbActiveModal,
@ -15,25 +7,30 @@ import {
NgbNavItem, NgbNavItem,
NgbNavLink, NgbNavLink,
NgbNavOutlet, NgbNavOutlet,
NgbPagination, NgbTooltip NgbPagination,
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap'; } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import { debounceTime, distinctUntilChanged, forkJoin, switchMap, tap } from 'rxjs'; import {debounceTime, distinctUntilChanged, forkJoin, switchMap, tap} from 'rxjs';
import { ConfirmService } from 'src/app/shared/confirm.service'; import {ConfirmService} from 'src/app/shared/confirm.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component'; import {SelectionModel} from 'src/app/typeahead/_components/typeahead.component';
import { CollectionTag } from 'src/app/_models/collection-tag'; import {UserCollection} from 'src/app/_models/collection-tag';
import { Pagination } from 'src/app/_models/pagination'; import {Pagination} from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series'; import {Series} from 'src/app/_models/series';
import { CollectionTagService } from 'src/app/_services/collection-tag.service'; import {CollectionTagService} from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service'; import {ImageService} from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service'; import {LibraryService} from 'src/app/_services/library.service';
import { SeriesService } from 'src/app/_services/series.service'; import {SeriesService} from 'src/app/_services/series.service';
import { UploadService } from 'src/app/_services/upload.service'; import {UploadService} from 'src/app/_services/upload.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CommonModule} from "@angular/common"; import {CommonModule, NgTemplateOutlet} from "@angular/common";
import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component"; import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component";
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {translate, TranslocoDirective} from "@ngneat/transloco";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {FilterPipe} from "../../../_pipes/filter.pipe";
import {ScrobbleError} from "../../../_models/scrobbling/scrobble-error";
import {AccountService} from "../../../_services/account.service";
enum TabID { enum TabID {
@ -45,14 +42,33 @@ enum TabID {
@Component({ @Component({
selector: 'app-edit-collection-tags', selector: 'app-edit-collection-tags',
standalone: true, standalone: true,
imports: [CommonModule, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination, CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective], imports: [NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination,
CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective, NgTemplateOutlet, FilterPipe],
templateUrl: './edit-collection-tags.component.html', templateUrl: './edit-collection-tags.component.html',
styleUrls: ['./edit-collection-tags.component.scss'], styleUrls: ['./edit-collection-tags.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class EditCollectionTagsComponent implements OnInit { export class EditCollectionTagsComponent implements OnInit {
@Input({required: true}) tag!: CollectionTag; public readonly modal = inject(NgbActiveModal);
public readonly utilityService = inject(UtilityService);
private readonly destroyRef = inject(DestroyRef);
private readonly seriesService = inject(SeriesService);
private readonly collectionService = inject(CollectionTagService);
private readonly toastr = inject(ToastrService);
private readonly confirmService = inject(ConfirmService);
private readonly libraryService = inject(LibraryService);
private readonly imageService = inject(ImageService);
private readonly uploadService = inject(UploadService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly accountService = inject(AccountService);
protected readonly Breakpoint = Breakpoint;
protected readonly TabID = TabID;
protected readonly ScrobbleProvider = ScrobbleProvider;
@Input({required: true}) tag!: UserCollection;
series: Array<Series> = []; series: Array<Series> = [];
selections!: SelectionModel<Series>; selections!: SelectionModel<Series>;
isLoading: boolean = true; isLoading: boolean = true;
@ -64,25 +80,18 @@ export class EditCollectionTagsComponent implements OnInit {
active = TabID.General; active = TabID.General;
imageUrls: Array<string> = []; imageUrls: Array<string> = [];
selectedCover: string = ''; selectedCover: string = '';
private readonly destroyRef = inject(DestroyRef); formGroup = new FormGroup({'filter': new FormControl('', [])});
get hasSomeSelected() { get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected(); return this.selections != null && this.selections.hasSomeSelected();
} }
get Breakpoint() { filterList = (listItem: Series) => {
return Breakpoint; const query = (this.formGroup.get('filter')?.value || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(query) >= 0 || listItem.localizedName.toLowerCase().indexOf(query) >= 0;
} }
get TabID() {
return TabID;
}
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
private collectionService: CollectionTagService, private toastr: ToastrService,
private confirmService: ConfirmService, private libraryService: LibraryService,
private imageService: ImageService, private uploadService: UploadService,
public utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void { ngOnInit(): void {
if (this.pagination == undefined) { if (this.pagination == undefined) {
@ -96,6 +105,20 @@ export class EditCollectionTagsComponent implements OnInit {
promoted: new FormControl(this.tag.promoted, { nonNullable: true, validators: [] }), promoted: new FormControl(this.tag.promoted, { nonNullable: true, validators: [] }),
}); });
if (this.tag.source !== ScrobbleProvider.Kavita) {
this.collectionTagForm.get('title')?.disable();
this.collectionTagForm.get('summary')?.disable();
}
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (!user) return;
if (!this.accountService.hasPromoteRole(user)) {
this.collectionTagForm.get('promoted')?.disable();
this.cdRef.markForCheck();
}
});
this.collectionTagForm.get('title')?.valueChanges.pipe( this.collectionTagForm.get('title')?.valueChanges.pipe(
debounceTime(100), debounceTime(100),
distinctUntilChanged(), distinctUntilChanged(),
@ -169,6 +192,9 @@ export class EditCollectionTagsComponent implements OnInit {
const unselectedIds = this.selections.unselected().map(s => s.id); const unselectedIds = this.selections.unselected().map(s => s.id);
const tag = this.collectionTagForm.value; const tag = this.collectionTagForm.value;
tag.id = this.tag.id; tag.id = this.tag.id;
tag.title = this.collectionTagForm.get('title')!.value;
tag.summary = this.collectionTagForm.get('summary')!.value;
if (unselectedIds.length == this.series.length && if (unselectedIds.length == this.series.length &&
!await this.confirmService.confirm(translate('toasts.no-series-collection-warning'))) { !await this.confirmService.confirm(translate('toasts.no-series-collection-warning'))) {
@ -177,9 +203,13 @@ export class EditCollectionTagsComponent implements OnInit {
const apis = [ const apis = [
this.collectionService.updateTag(tag), this.collectionService.updateTag(tag),
this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id))
]; ];
const unselectedSeries = this.selections.unselected().map(s => s.id);
if (unselectedSeries.length > 0) {
apis.push(this.collectionService.updateSeriesForTag(tag, unselectedSeries));
}
if (selectedIndex > 0) { if (selectedIndex > 0) {
apis.push(this.uploadService.updateCollectionCoverImage(this.tag.id, this.selectedCover)); apis.push(this.uploadService.updateCollectionCoverImage(this.tag.id, this.selectedCover));
} }
@ -207,5 +237,4 @@ export class EditCollectionTagsComponent implements OnInit {
}); });
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
} }

View File

@ -72,13 +72,15 @@
<div class="row g-0"> <div class="row g-0">
<div class="col-lg-8 col-md-12 pe-2"> <div class="col-lg-8 col-md-12 pe-2">
<div class="mb-3"> <div class="mb-3">
<label for="collections" class="form-label">{{t('collections-label')}}</label> <label for="language" class="form-label">{{t('language-label')}}</label>
<app-typeahead (selectedData)="updateCollections($event)" [settings]="collectionTagSettings" [locked]="true"> <app-typeahead (selectedData)="updateLanguage($event);metadata.languageLocked = true;" [settings]="languageSettings"
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
(newItemAdded)="metadata.languageLocked = true">
<ng-template #badgeItem let-item let-position="idx"> <ng-template #badgeItem let-item let-position="idx">
{{item.title}} {{item.title}}
</ng-template> </ng-template>
<ng-template #optionItem let-item let-position="idx"> <ng-template #optionItem let-item let-position="idx">
{{item.title}} {{item.title}} ({{item.isoCode}})
</ng-template> </ng-template>
</app-typeahead> </app-typeahead>
</div> </div>
@ -138,22 +140,10 @@
</div> </div>
<div class="row g-0"> <div class="row g-0">
<div class="col-lg-4 col-md-12 pe-2"> <!-- <div class="col-lg-4 col-md-12 pe-2">-->
<div class="mb-3"> <!-- -->
<label for="language" class="form-label">{{t('language-label')}}</label> <!-- </div>-->
<app-typeahead (selectedData)="updateLanguage($event);metadata.languageLocked = true;" [settings]="languageSettings" <div class="col-lg-6 col-md-12 pe-2">
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
(newItemAdded)="metadata.languageLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}} ({{item.isoCode}})
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-lg-4 col-md-12 pe-2">
<div class="mb-3"> <div class="mb-3">
<label for="age-rating" class="form-label">{{t('age-rating-label')}}</label> <label for="age-rating" class="form-label">{{t('age-rating-label')}}</label>
<div class="input-group {{metadata.ageRatingLocked ? 'lock-active' : ''}}"> <div class="input-group {{metadata.ageRatingLocked ? 'lock-active' : ''}}">
@ -164,7 +154,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-4 col-md-12"> <div class="col-lg-6 col-md-12">
<div class="mb-3"> <div class="mb-3">
<label for="publication-status" class="form-label">{{t('publication-status-label')}}</label> <label for="publication-status" class="form-label">{{t('publication-status-label')}}</label>
<div class="input-group {{metadata.publicationStatusLocked ? 'lock-active' : ''}}"> <div class="input-group {{metadata.publicationStatusLocked ? 'lock-active' : ''}}">

View File

@ -22,7 +22,6 @@ import { map } from 'rxjs/operators';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { TypeaheadSettings } from 'src/app/typeahead/_models/typeahead-settings'; import { TypeaheadSettings } from 'src/app/typeahead/_models/typeahead-settings';
import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter'; import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { Genre } from 'src/app/_models/metadata/genre'; import { Genre } from 'src/app/_models/metadata/genre';
import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto'; import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto';
import { Language } from 'src/app/_models/metadata/language'; import { Language } from 'src/app/_models/metadata/language';
@ -31,7 +30,6 @@ import { Person, PersonRole } from 'src/app/_models/metadata/person';
import { Series } from 'src/app/_models/series'; import { Series } from 'src/app/_models/series';
import { SeriesMetadata } from 'src/app/_models/metadata/series-metadata'; import { SeriesMetadata } from 'src/app/_models/metadata/series-metadata';
import { Tag } from 'src/app/_models/tag'; import { Tag } from 'src/app/_models/tag';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service'; import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service'; import { LibraryService } from 'src/app/_services/library.service';
import { MetadataService } from 'src/app/_services/metadata.service'; import { MetadataService } from 'src/app/_services/metadata.service';
@ -119,7 +117,6 @@ export class EditSeriesModalComponent implements OnInit {
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
public readonly imageService = inject(ImageService); public readonly imageService = inject(ImageService);
private readonly libraryService = inject(LibraryService); private readonly libraryService = inject(LibraryService);
private readonly collectionService = inject(CollectionTagService);
private readonly uploadService = inject(UploadService); private readonly uploadService = inject(UploadService);
private readonly metadataService = inject(MetadataService); private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
@ -155,10 +152,8 @@ export class EditSeriesModalComponent implements OnInit {
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings(); tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings(); languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {}; peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
collectionTagSettings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings(); genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
collectionTags: CollectionTag[] = [];
tags: Tag[] = []; tags: Tag[] = [];
genres: Genre[] = []; genres: Genre[] = [];
ageRatings: Array<AgeRatingDto> = []; ageRatings: Array<AgeRatingDto> = [];
@ -330,44 +325,15 @@ export class EditSeriesModalComponent implements OnInit {
setupTypeaheads() { setupTypeaheads() {
forkJoin([ forkJoin([
this.setupCollectionTagsSettings(),
this.setupTagSettings(), this.setupTagSettings(),
this.setupGenreTypeahead(), this.setupGenreTypeahead(),
this.setupPersonTypeahead(), this.setupPersonTypeahead(),
this.setupLanguageTypeahead() this.setupLanguageTypeahead()
]).subscribe(results => { ]).subscribe(results => {
this.collectionTags = this.metadata.collectionTags;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
} }
setupCollectionTagsSettings() {
this.collectionTagSettings.minCharacters = 0;
this.collectionTagSettings.multiple = true;
this.collectionTagSettings.id = 'collections';
this.collectionTagSettings.unique = true;
this.collectionTagSettings.addIfNonExisting = true;
this.collectionTagSettings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.collectionTagSettings.compareFn(items, filter)));
this.collectionTagSettings.addTransformFn = ((title: string) => {
return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false };
});
this.collectionTagSettings.compareFn = (options: CollectionTag[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.collectionTagSettings.compareFnForAdd = (options: CollectionTag[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.collectionTagSettings.selectionCompareFn = (a: CollectionTag, b: CollectionTag) => {
return a.title === b.title;
}
if (this.metadata.collectionTags) {
this.collectionTagSettings.savedData = this.metadata.collectionTags;
}
return of(true);
}
setupTagSettings() { setupTagSettings() {
this.tagsSettings.minCharacters = 0; this.tagsSettings.minCharacters = 0;
this.tagsSettings.multiple = true; this.tagsSettings.multiple = true;
@ -545,10 +511,6 @@ export class EditSeriesModalComponent implements OnInit {
}); });
} }
fetchCollectionTags(filter: string = '') {
return this.collectionService.search(filter);
}
updateWeblinks(items: Array<string>) { updateWeblinks(items: Array<string>) {
this.metadata.webLinks = items.map(s => s.replaceAll(',', '%2C')).join(','); this.metadata.webLinks = items.map(s => s.replaceAll(',', '%2C')).join(',');
} }
@ -559,7 +521,7 @@ export class EditSeriesModalComponent implements OnInit {
const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0; const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0;
const apis = [ const apis = [
this.seriesService.updateMetadata(this.metadata, this.collectionTags) this.seriesService.updateMetadata(this.metadata)
]; ];
// We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image // We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image
@ -585,10 +547,6 @@ export class EditSeriesModalComponent implements OnInit {
}); });
} }
updateCollections(tags: CollectionTag[]) {
this.collectionTags = tags;
this.cdRef.markForCheck();
}
updateTags(tags: Tag[]) { updateTags(tags: Tag[]) {
this.tags = tags; this.tags = tags;

View File

@ -4,7 +4,7 @@ import {ReplaySubject} from 'rxjs';
import {filter} from 'rxjs/operators'; import {filter} from 'rxjs/operators';
import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service'; import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service';
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream'; type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection';
/** /**
* Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops. * Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops.
@ -155,6 +155,10 @@ export class BulkSelectionService {
return this.applyFilterToList(this.actionFactory.getSideNavStreamActions(callback), [Action.MarkAsInvisible, Action.MarkAsVisible]); return this.applyFilterToList(this.actionFactory.getSideNavStreamActions(callback), [Action.MarkAsInvisible, Action.MarkAsVisible]);
} }
if (Object.keys(this.selectedCards).filter(item => item === 'collection').length > 0) {
return this.applyFilterToList(this.actionFactory.getCollectionTagActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]);
}
return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions); return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions);
} }

View File

@ -31,45 +31,53 @@
</div> </div>
} }
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)" *ngIf="allowSelection"> @if (allowSelection) {
<input type="checkbox" class="form-check-input" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}"> <div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)">
</div> <input type="checkbox" class="form-check-input" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
<div class="count" *ngIf="count > 1">
<span class="badge bg-primary">{{count}}</span>
</div>
<div class="card-overlay"></div>
@if (overlayInformation | safeHtml; as info) {
<div class="overlay-information {{centerOverlay ? 'overlay-information--centered' : ''}}" *ngIf="info !== '' || info !== undefined">
<div class="position-relative">
<span class="card-title library mx-auto" style="width: auto;" [ngbTooltip]="info" placement="top" [innerHTML]="info"></span>
</div>
</div> </div>
} }
</div>
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0"> @if (count > 1) {
<div> <div class="count">
<span class="badge bg-primary">{{count}}</span>
</div>
}
<div class="card-overlay"></div>
@if (overlayInformation | safeHtml; as info) {
@if (info !== '' || info !== null) {
<div class="overlay-information {{centerOverlay ? 'overlay-information--centered' : ''}}">
<div class="position-relative">
<span class="card-title library mx-auto" style="width: auto;" [ngbTooltip]="info" placement="top" [innerHTML]="info"></span>
</div>
</div>
}
}
</div>
@if (title.length > 0 || actions.length > 0) {
<div class="card-body">
<div>
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0"> <span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
<span *ngIf="isPromoted()"> <app-promoted-icon [promoted]="isPromoted()"></app-promoted-icon>
<i class="fa fa-angle-double-up" aria-hidden="true"></i> <app-series-format [format]="format"></app-series-format>
<span class="visually-hidden">(promoted)</span>
</span>
<ng-container *ngIf="format | mangaFormat as formatString">
<i class="fa {{format | mangaFormatIcon}} me-1" aria-hidden="true" *ngIf="format !== MangaFormat.UNKNOWN" title="{{formatString}}"></i>
<span class="visually-hidden">{{formatString}}</span>
</ng-container>
{{title}} {{title}}
</span> </span>
<span class="card-actions float-end" *ngIf="actions && actions.length > 0"> <span class="card-actions float-end" *ngIf="actions && actions.length > 0">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables> <app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
</span> </span>
</div>
@if (subtitleTemplate) {
<div style="text-align: center">
<ng-container [ngTemplateOutlet]="subtitleTemplate" [ngTemplateOutletContext]="{ $implicit: entity }"></ng-container>
</div>
}
@if (!suppressLibraryLink && libraryName) {
<a class="card-title library" [routerLink]="['/library', libraryId]" routerLinkActive="router-link-active">
{{libraryName | sentenceCase}}
</a>
}
</div> </div>
<span class="card-title library" [ngbTooltip]="subtitle" placement="top" *ngIf="subtitle.length > 0">{{subtitle}}</span> }
<a class="card-title library" [routerLink]="['/library', libraryId]" routerLinkActive="router-link-active" *ngIf="!suppressLibraryLink && libraryName">
{{libraryName | sentenceCase}}
</a>
</div>
</div> </div>
</ng-container> </ng-container>

View File

@ -1,20 +1,20 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component, ContentChild, DestroyRef,
EventEmitter, EventEmitter,
HostListener, HostListener,
inject, inject,
Input, Input,
OnInit, OnInit,
Output Output, TemplateRef
} from '@angular/core'; } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators'; import { filter, map } from 'rxjs/operators';
import { DownloadEvent, DownloadService } from 'src/app/shared/_services/download.service'; import { DownloadEvent, DownloadService } from 'src/app/shared/_services/download.service';
import { UtilityService } from 'src/app/shared/_services/utility.service'; import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter'; import { Chapter } from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag'; import { UserCollection } from 'src/app/_models/collection-tag';
import { UserProgressUpdateEvent } from 'src/app/_models/events/user-progress-update-event'; import { UserProgressUpdateEvent } from 'src/app/_models/events/user-progress-update-event';
import { MangaFormat } from 'src/app/_models/manga-format'; import { MangaFormat } from 'src/app/_models/manga-format';
import { PageBookmark } from 'src/app/_models/readers/page-bookmark'; import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
@ -44,6 +44,8 @@ import {CardActionablesComponent} from "../../_single-module/card-actionables/ca
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.component";
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
@Component({ @Component({
selector: 'app-card-item', selector: 'app-card-item',
@ -62,7 +64,9 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
RouterLink, RouterLink,
TranslocoModule, TranslocoModule,
SafeHtmlPipe, SafeHtmlPipe,
RouterLinkActive RouterLinkActive,
PromotedIconComponent,
SeriesFormatComponent
], ],
templateUrl: './card-item.component.html', templateUrl: './card-item.component.html',
styleUrls: ['./card-item.component.scss'], styleUrls: ['./card-item.component.scss'],
@ -81,6 +85,7 @@ export class CardItemComponent implements OnInit {
private readonly scrollService = inject(ScrollService); private readonly scrollService = inject(ScrollService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly actionFactoryService = inject(ActionFactoryService); private readonly actionFactoryService = inject(ActionFactoryService);
protected readonly MangaFormat = MangaFormat; protected readonly MangaFormat = MangaFormat;
/** /**
@ -91,10 +96,6 @@ export class CardItemComponent implements OnInit {
* Name of the card * Name of the card
*/ */
@Input() title = ''; @Input() title = '';
/**
* Shows below the title. Defaults to not visible
*/
@Input() subtitle = '';
/** /**
* Any actions to perform on the card * Any actions to perform on the card
*/ */
@ -114,7 +115,7 @@ export class CardItemComponent implements OnInit {
/** /**
* This is the entity we are representing. It will be returned if an action is executed. * This is the entity we are representing. It will be returned if an action is executed.
*/ */
@Input({required: true}) entity!: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem | NextExpectedChapter; @Input({required: true}) entity!: Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter;
/** /**
* If the entity is selected or not. * If the entity is selected or not.
*/ */
@ -147,6 +148,7 @@ export class CardItemComponent implements OnInit {
* When the card is selected. * When the card is selected.
*/ */
@Output() selection = new EventEmitter<boolean>(); @Output() selection = new EventEmitter<boolean>();
@ContentChild('subtitle') subtitleTemplate!: TemplateRef<any>;
/** /**
* Library name item belongs to * Library name item belongs to
*/ */
@ -351,7 +353,7 @@ export class CardItemComponent implements OnInit {
isPromoted() { isPromoted() {
const tag = this.entity as CollectionTag; const tag = this.entity as UserCollection;
return tag.hasOwnProperty('promoted') && tag.promoted; return tag.hasOwnProperty('promoted') && tag.promoted;
} }
@ -378,5 +380,10 @@ export class CardItemComponent implements OnInit {
// this.actions = this.actions.filter(a => a.title !== 'Send To'); // this.actions = this.actions.filter(a => a.title !== 'Send To');
// } // }
} }
// this.actions = this.actions.filter(a => {
// if (!a.isAllowed) return true;
// return a.isAllowed(a, this.entity);
// });
} }
} }

View File

@ -64,7 +64,7 @@
<ng-container> <ng-container>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('format-title')" [clickable]="true" <app-icon-and-title [label]="t('format-title')" [clickable]="true"
[fontClasses]="'fa ' + (series.format | mangaFormatIcon)" [fontClasses]="series.format | mangaFormatIcon"
(click)="handleGoTo(FilterField.Formats, series.format)" [title]="t('format-title')"> (click)="handleGoTo(FilterField.Formats, series.format)" [title]="t('format-title')">
{{series.format | mangaFormat}} {{series.format | mangaFormat}}
</app-icon-and-title> </app-icon-and-title>

View File

@ -3,6 +3,7 @@
<h2 title>{{t('title')}}</h2> <h2 title>{{t('title')}}</h2>
<h6 subtitle>{{t('item-count', {num: collections.length | number})}}</h6> <h6 subtitle>{{t('item-count', {num: collections.length | number})}}</h6>
</app-side-nav-companion-bar> </app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout <app-card-detail-layout
[isLoading]="isLoading" [isLoading]="isLoading"
[items]="collections" [items]="collections"
@ -13,12 +14,21 @@
<ng-template #cardItem let-item let-position="idx"> <ng-template #cardItem let-item let-position="idx">
<app-card-item [title]="item.title" [entity]="item" [actions]="collectionTagActions" <app-card-item [title]="item.title" [entity]="item" [actions]="collectionTagActions"
[imageUrl]="imageService.getCollectionCoverImage(item.id)" [imageUrl]="imageService.getCollectionCoverImage(item.id)"
(clicked)="loadCollection(item)"></app-card-item> (clicked)="loadCollection(item)"
(selection)="bulkSelectionService.handleCardSelection('collection', position, collections.length, $event)"
[selected]="bulkSelectionService.isCardSelected('collection', position)" [allowSelection]="true">
<ng-template #subtitle>
<app-collection-owner [collection]="item"></app-collection-owner>
</ng-template>
</app-card-item>
</ng-template> </ng-template>
<ng-template #noData> <ng-template #noData>
{{t('no-data')}} {{t('no-data')}}
<ng-container *ngIf="isAdmin$ | async"> {{t('create-one-part-1')}} <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/collections" rel="noopener noreferrer" target="_blank">{{t('create-one-part-2')}}<i class="fa fa-external-link-alt ms-1" aria-hidden="true"></i></a></ng-container> @if(isAdmin$ | async) {
{{t('create-one-part-1')}} <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/collections" rel="noopener noreferrer" target="_blank">{{t('create-one-part-2')}}<i class="fa fa-external-link-alt ms-1" aria-hidden="true"></i></a>
}
</ng-template> </ng-template>
</app-card-detail-layout> </app-card-detail-layout>

View File

@ -4,6 +4,7 @@ import {
Component, Component,
DestroyRef, DestroyRef,
EventEmitter, EventEmitter,
HostListener,
inject, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
@ -13,7 +14,7 @@ import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {map, of} from 'rxjs'; import {map, of} from 'rxjs';
import {Observable} from 'rxjs/internal/Observable'; import {Observable} from 'rxjs/internal/Observable';
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 {CollectionTag} from 'src/app/_models/collection-tag'; import {UserCollection} from 'src/app/_models/collection-tag';
import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import {Tag} from 'src/app/_models/tag'; import {Tag} from 'src/app/_models/tag';
import {AccountService} from 'src/app/_services/account.service'; import {AccountService} from 'src/app/_services/account.service';
@ -22,14 +23,24 @@ import {CollectionTagService} from 'src/app/_services/collection-tag.service';
import {ImageService} from 'src/app/_services/image.service'; import {ImageService} from 'src/app/_services/image.service';
import {JumpbarService} from 'src/app/_services/jumpbar.service'; import {JumpbarService} from 'src/app/_services/jumpbar.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common'; import {AsyncPipe, DecimalPipe} from '@angular/common';
import {CardItemComponent} from '../../../cards/card-item/card-item.component'; import {CardItemComponent} from '../../../cards/card-item/card-item.component';
import {CardDetailLayoutComponent} from '../../../cards/card-detail-layout/card-detail-layout.component'; import {CardDetailLayoutComponent} from '../../../cards/card-detail-layout/card-detail-layout.component';
import { import {
SideNavCompanionBarComponent SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
import {CollectionOwnerComponent} from "../collection-owner/collection-owner.component";
import {User} from "../../../_models/user";
import {BulkOperationsComponent} from "../../../cards/bulk-operations/bulk-operations.component";
import {BulkSelectionService} from "../../../cards/bulk-selection.service";
import {SeriesCardComponent} from "../../../cards/series-card/series-card.component";
import {ActionService} from "../../../_services/action.service";
import {KEY_CODES} from "../../../shared/_services/utility.service";
@Component({ @Component({
@ -38,51 +49,87 @@ import {ToastrService} from "ngx-toastr";
styleUrls: ['./all-collections.component.scss'], styleUrls: ['./all-collections.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [SideNavCompanionBarComponent, CardDetailLayoutComponent, CardItemComponent, NgIf, AsyncPipe, DecimalPipe, TranslocoDirective] imports: [SideNavCompanionBarComponent, CardDetailLayoutComponent, CardItemComponent, AsyncPipe, DecimalPipe, TranslocoDirective, ProviderImagePipe, ProviderNamePipe, CollectionOwnerComponent, BulkOperationsComponent, SeriesCardComponent]
}) })
export class AllCollectionsComponent implements OnInit { export class AllCollectionsComponent implements OnInit {
isLoading: boolean = true;
collections: CollectionTag[] = [];
collectionTagActions: ActionItem<CollectionTag>[] = [];
jumpbarKeys: Array<JumpKey> = [];
trackByIdentity = (index: number, item: CollectionTag) => `${item.id}_${item.title}`;
isAdmin$: Observable<boolean> = of(false);
filterOpen: EventEmitter<boolean> = new EventEmitter();
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly translocoService = inject(TranslocoService); private readonly translocoService = inject(TranslocoService);
private readonly toastr = inject(ToastrService); private readonly toastr = inject(ToastrService);
private readonly collectionService = inject(CollectionTagService);
private readonly router = inject(Router);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly modalService = inject(NgbModal);
private readonly titleService = inject(Title);
private readonly jumpbarService = inject(JumpbarService);
private readonly cdRef = inject(ChangeDetectorRef);
public readonly imageService = inject(ImageService);
public readonly accountService = inject(AccountService);
public readonly bulkSelectionService = inject(BulkSelectionService);
public readonly actionService = inject(ActionService);
constructor(private collectionService: CollectionTagService, private router: Router, protected readonly ScrobbleProvider = ScrobbleProvider;
private actionFactoryService: ActionFactoryService, private modalService: NgbModal,
private titleService: Title, private jumpbarService: JumpbarService, isLoading: boolean = true;
private readonly cdRef: ChangeDetectorRef, public imageService: ImageService, collections: UserCollection[] = [];
public accountService: AccountService) { collectionTagActions: ActionItem<UserCollection>[] = [];
jumpbarKeys: Array<JumpKey> = [];
isAdmin$: Observable<boolean> = of(false);
filterOpen: EventEmitter<boolean> = new EventEmitter();
trackByIdentity = (index: number, item: UserCollection) => `${item.id}_${item.title}`;
user!: User;
@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;
}
}
constructor() {
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.titleService.setTitle('Kavita - ' + this.translocoService.translate('all-collections.title')); this.titleService.setTitle('Kavita - ' + this.translocoService.translate('all-collections.title'));
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (user) {
this.user = user;
this.cdRef.markForCheck();
}
});
} }
ngOnInit() { ngOnInit() {
this.loadPage(); this.loadPage();
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
this.cdRef.markForCheck(); this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (!user) return;
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this))
.filter(action => this.collectionService.actionListFilter(action, user));
this.cdRef.markForCheck();
});
this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(user => { this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(user => {
if (!user) return false; if (!user) return false;
return this.accountService.hasAdminRole(user); return this.accountService.hasAdminRole(user);
})); }));
} }
loadCollection(item: CollectionTag) { loadCollection(item: UserCollection) {
this.router.navigate(['collections', item.id]); this.router.navigate(['collections', item.id]);
this.loadPage();
} }
loadPage() { loadPage() {
this.isLoading = true; this.isLoading = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.collectionService.allTags().subscribe(tags => { this.collectionService.allCollections().subscribe(tags => {
this.collections = [...tags]; this.collections = [...tags];
this.isLoading = false; this.isLoading = false;
this.jumpbarKeys = this.jumpbarService.getJumpKeys(tags, (t: Tag) => t.title); this.jumpbarKeys = this.jumpbarService.getJumpKeys(tags, (t: Tag) => t.title);
@ -90,8 +137,20 @@ export class AllCollectionsComponent implements OnInit {
}); });
} }
handleCollectionActionCallback(action: ActionItem<CollectionTag>, collectionTag: CollectionTag) { handleCollectionActionCallback(action: ActionItem<UserCollection>, collectionTag: UserCollection) {
if (collectionTag.owner != this.user.username) {
this.toastr.error(translate('toasts.collection-not-owned'));
return;
}
switch (action.action) { switch (action.action) {
case Action.Promote:
this.collectionService.promoteMultipleCollections([collectionTag.id], true).subscribe();
break;
case Action.UnPromote:
this.collectionService.promoteMultipleCollections([collectionTag.id], false).subscribe();
break;
case(Action.Delete): case(Action.Delete):
this.collectionService.deleteTag(collectionTag.id).subscribe(res => { this.collectionService.deleteTag(collectionTag.id).subscribe(res => {
this.toastr.success(res); this.toastr.success(res);
@ -110,4 +169,33 @@ export class AllCollectionsComponent implements OnInit {
break; break;
} }
} }
bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedCollectionIndexies = this.bulkSelectionService.getSelectedCardsForSource('collection');
const selectedCollections = this.collections.filter((col, index: number) => selectedCollectionIndexies.includes(index + ''));
switch (action.action) {
case Action.Promote:
this.actionService.promoteMultipleCollections(selectedCollections, true, (success) => {
if (!success) return;
this.bulkSelectionService.deselectAll();
this.loadPage();
});
break;
case Action.UnPromote:
this.actionService.promoteMultipleCollections(selectedCollections, false, (success) => {
if (!success) return;
this.bulkSelectionService.deselectAll();
this.loadPage();
});
break;
case Action.Delete:
this.actionService.deleteMultipleCollections(selectedCollections, (successful) => {
if (!successful) return;
this.loadPage();
this.bulkSelectionService.deselectAll();
});
break;
}
}
} }

View File

@ -23,7 +23,7 @@ import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection
import {FilterSettings} from 'src/app/metadata-filter/filter-settings'; import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
import {CollectionTag} from 'src/app/_models/collection-tag'; import {UserCollection} from 'src/app/_models/collection-tag';
import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-to-collection-event'; import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-to-collection-event';
import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import {Pagination} from 'src/app/_models/pagination'; import {Pagination} from 'src/app/_models/pagination';
@ -52,6 +52,8 @@ import {CardActionablesComponent} from "../../../_single-module/card-actionables
import {FilterField} from "../../../_models/metadata/v2/filter-field"; import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2"; import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
import {AccountService} from "../../../_services/account.service";
import {User} from "../../../_models/user";
@Component({ @Component({
selector: 'app-collection-detail', selector: 'app-collection-detail',
@ -63,20 +65,41 @@ import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
}) })
export class CollectionDetailComponent implements OnInit, AfterContentChecked { export class CollectionDetailComponent implements OnInit, AfterContentChecked {
public readonly imageService = inject(ImageService);
public readonly bulkSelectionService = inject(BulkSelectionService);
private readonly destroyRef = inject(DestroyRef);
private readonly translocoService = inject(TranslocoService);
private readonly collectionService = inject(CollectionTagService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly seriesService = inject(SeriesService);
private readonly toastr = inject(ToastrService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly accountService = inject(AccountService);
private readonly modalService = inject(NgbModal);
private readonly titleService = inject(Title);
private readonly jumpbarService = inject(JumpbarService);
private readonly actionService = inject(ActionService);
private readonly messageHub = inject(MessageHubService);
private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly utilityService = inject(UtilityService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly scrollService = inject(ScrollService);
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined; @ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined; @ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
destroyRef = inject(DestroyRef);
translocoService = inject(TranslocoService);
collectionTag!: CollectionTag;
collectionTag!: UserCollection;
isLoading: boolean = true; isLoading: boolean = true;
series: Array<Series> = []; series: Array<Series> = [];
pagination: Pagination = new Pagination(); pagination: Pagination = new Pagination();
collectionTagActions: ActionItem<CollectionTag>[] = []; collectionTagActions: ActionItem<UserCollection>[] = [];
filter: SeriesFilterV2 | undefined = undefined; filter: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings(); filterSettings: FilterSettings = new FilterSettings();
summary: string = ''; summary: string = '';
user!: User;
actionInProgress: boolean = false; actionInProgress: boolean = false;
filterActiveCheck!: SeriesFilterV2; filterActiveCheck!: SeriesFilterV2;
@ -153,12 +176,8 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
return 'calc(var(--vh)*100 - ' + totalHeight + 'px)'; return 'calc(var(--vh)*100 - ' + totalHeight + 'px)';
} }
constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute,
private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService, constructor(@Inject(DOCUMENT) private document: Document) {
private modalService: NgbModal, private titleService: Title, private jumpbarService: JumpbarService,
public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService,
private filterUtilityService: FilterUtilitiesService, private utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
const routeId = this.route.snapshot.paramMap.get('id'); const routeId = this.route.snapshot.paramMap.get('id');
@ -184,7 +203,14 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
} }
ngOnInit(): void { ngOnInit(): void {
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)); this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (!user) return;
this.user = user;
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this))
.filter(action => this.collectionService.actionListFilter(action, user));
this.cdRef.markForCheck();
});
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(2000)).subscribe(event => { this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(2000)).subscribe(event => {
if (event.event == EVENTS.SeriesAddedToCollection) { if (event.event == EVENTS.SeriesAddedToCollection) {
@ -217,11 +243,10 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
} }
updateTag(tagId: number) { updateTag(tagId: number) {
this.collectionService.allTags().subscribe(tags => { this.collectionService.allCollections().subscribe(tags => {
const matchingTags = tags.filter(t => t.id === tagId); const matchingTags = tags.filter(t => t.id === tagId);
if (matchingTags.length === 0) { if (matchingTags.length === 0) {
this.toastr.error(this.translocoService.translate('errors.collection-invalid-access')); this.toastr.error(this.translocoService.translate('errors.collection-invalid-access'));
// TODO: Why would access need to be checked? Even if a id was guessed, the series wouldn't return
this.router.navigateByUrl('/'); this.router.navigateByUrl('/');
return; return;
} }
@ -261,8 +286,18 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
}); });
} }
handleCollectionActionCallback(action: ActionItem<CollectionTag>, collectionTag: CollectionTag) { handleCollectionActionCallback(action: ActionItem<UserCollection>, collectionTag: UserCollection) {
if (collectionTag.owner != this.user.username) {
this.toastr.error(translate('toasts.collection-not-owned'));
return;
}
switch (action.action) { switch (action.action) {
case Action.Promote:
this.collectionService.promoteMultipleCollections([this.collectionTag.id], true).subscribe();
break;
case Action.UnPromote:
this.collectionService.promoteMultipleCollections([this.collectionTag.id], false).subscribe();
break;
case(Action.Edit): case(Action.Edit):
this.openEditCollectionTagModal(this.collectionTag); this.openEditCollectionTagModal(this.collectionTag);
break; break;
@ -283,7 +318,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
} }
} }
openEditCollectionTagModal(collectionTag: CollectionTag) { openEditCollectionTagModal(collectionTag: UserCollection) {
const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true }); const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
modalRef.componentInstance.tag = this.collectionTag; modalRef.componentInstance.tag = this.collectionTag;
modalRef.closed.subscribe((results: {success: boolean, coverImageUpdated: boolean}) => { modalRef.closed.subscribe((results: {success: boolean, coverImageUpdated: boolean}) => {
@ -291,6 +326,4 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
this.loadPage(); this.loadPage();
}); });
} }
protected readonly undefined = undefined;
} }

View File

@ -0,0 +1,14 @@
<ng-container *transloco="let t; read: 'collection-owner'">
@if (accountService.currentUser$ | async; as user) {
<div class="fw-light text-accent">
{{t('collection-created-label', {owner: collection.owner})}}
@if(collection.source !== ScrobbleProvider.Kavita) {
{{t('collection-via-label')}}
<app-image [imageUrl]="collection.source | providerImage"
width="16px" height="16px"
[ngbTooltip]="collection.source | providerName"
[attr.aria-label]="collection.source | providerName"></app-image>
}
</div>
}
</ng-container>

View File

@ -0,0 +1,4 @@
.text-accent {
font-size: small;
color: var(---accent-text-color);
}

View File

@ -0,0 +1,35 @@
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
import {UserCollection} from "../../../_models/collection-tag";
import {TranslocoDirective} from "@ngneat/transloco";
import {AsyncPipe, JsonPipe} from "@angular/common";
import {AccountService} from "../../../_services/account.service";
import {ImageComponent} from "../../../shared/image/image.component";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'app-collection-owner',
standalone: true,
imports: [
ProviderImagePipe,
ProviderNamePipe,
TranslocoDirective,
AsyncPipe,
JsonPipe,
ImageComponent,
NgbTooltip
],
templateUrl: './collection-owner.component.html',
styleUrl: './collection-owner.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CollectionOwnerComponent {
protected readonly accountService = inject(AccountService);
protected readonly ScrobbleProvider = ScrobbleProvider;
@Input({required: true}) collection!: UserCollection;
}

View File

@ -287,7 +287,7 @@ export class MetadataFilterRowComponent implements OnInit {
return {value: status.id, label: status.title} return {value: status.id, label: status.title}
}))); })));
case FilterField.CollectionTags: case FilterField.CollectionTags:
return this.collectionTagService.allTags().pipe(map(statuses => statuses.map(status => { return this.collectionTagService.allCollections().pipe(map(statuses => statuses.map(status => {
return {value: status.id, label: status.title} return {value: status.id, label: status.title}
}))); })));
case FilterField.Characters: return this.getPersonOptions(PersonRole.Character); case FilterField.Characters: return this.getPersonOptions(PersonRole.Character);

View File

@ -74,11 +74,11 @@
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image> <app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image>
</div> </div>
<div class="ms-1"> <div class="ms-1">
<span>{{item.title}}</span> <div>
<span *ngIf="item.promoted"> <span>{{item.title}}</span>
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i> <app-promoted-icon [promoted]="item.promoted"></app-promoted-icon>
<span class="visually-hidden">{{t('promoted')}}</span> </div>
</span> <app-collection-owner [collection]="item"></app-collection-owner>
</div> </div>
</div> </div>
</ng-template> </ng-template>
@ -88,7 +88,7 @@
<div class="ms-1"> <div class="ms-1">
<span>{{item.title}}</span> <span>{{item.title}}</span>
<span *ngIf="item.promoted"> <span *ngIf="item.promoted">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i> &nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" [title]="t('promoted')"></i>
<span class="visually-hidden">{{t('promoted')}}</span> <span class="visually-hidden">{{t('promoted')}}</span>
</span> </span>
</div> </div>

View File

@ -14,7 +14,7 @@ import {NavigationEnd, Router, RouterLink, RouterLinkActive} from '@angular/rout
import {fromEvent} from 'rxjs'; import {fromEvent} from 'rxjs';
import {debounceTime, distinctUntilChanged, filter, tap} from 'rxjs/operators'; import {debounceTime, distinctUntilChanged, filter, tap} from 'rxjs/operators';
import {Chapter} from 'src/app/_models/chapter'; import {Chapter} from 'src/app/_models/chapter';
import {CollectionTag} from 'src/app/_models/collection-tag'; import {UserCollection} from 'src/app/_models/collection-tag';
import {Library} from 'src/app/_models/library/library'; import {Library} from 'src/app/_models/library/library';
import {MangaFile} from 'src/app/_models/manga-file'; import {MangaFile} from 'src/app/_models/manga-file';
import {PersonRole} from 'src/app/_models/metadata/person'; import {PersonRole} from 'src/app/_models/metadata/person';
@ -40,6 +40,11 @@ import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
import {FilterField} from "../../../_models/metadata/v2/filter-field"; import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {BookmarkSearchResult} from "../../../_models/search/bookmark-search-result"; import {BookmarkSearchResult} from "../../../_models/search/bookmark-search-result";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
import {CollectionOwnerComponent} from "../../../collections/_components/collection-owner/collection-owner.component";
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
@Component({ @Component({
selector: 'app-nav-header', selector: 'app-nav-header',
@ -47,7 +52,9 @@ import {BookmarkSearchResult} from "../../../_models/search/bookmark-search-resu
styleUrls: ['./nav-header.component.scss'], styleUrls: ['./nav-header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [NgIf, RouterLink, RouterLinkActive, NgOptimizedImage, GroupedTypeaheadComponent, ImageComponent, SeriesFormatComponent, EventsWidgetComponent, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, AsyncPipe, PersonRolePipe, SentenceCasePipe, TranslocoDirective] imports: [NgIf, RouterLink, RouterLinkActive, NgOptimizedImage, GroupedTypeaheadComponent, ImageComponent,
SeriesFormatComponent, EventsWidgetComponent, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem,
AsyncPipe, PersonRolePipe, SentenceCasePipe, TranslocoDirective, ProviderImagePipe, ProviderNamePipe, CollectionOwnerComponent, PromotedIconComponent]
}) })
export class NavHeaderComponent implements OnInit { export class NavHeaderComponent implements OnInit {
@ -242,7 +249,7 @@ export class NavHeaderComponent implements OnInit {
this.router.navigate(['library', item.id]); this.router.navigate(['library', item.id]);
} }
clickCollectionSearchResult(item: CollectionTag) { clickCollectionSearchResult(item: UserCollection) {
this.clearSearch(); this.clearSearch();
this.router.navigate(['collections', item.id]); this.router.navigate(['collections', item.id]);
} }
@ -267,4 +274,5 @@ export class NavHeaderComponent implements OnInit {
} }
protected readonly ScrobbleProvider = ScrobbleProvider;
} }

View File

@ -60,7 +60,6 @@ export class ReadingListDetailComponent implements OnInit {
isAdmin: boolean = false; isAdmin: boolean = false;
isLoading: boolean = false; isLoading: boolean = false;
accessibilityMode: boolean = false; accessibilityMode: boolean = false;
hasDownloadingRole: boolean = false;
readingListSummary: string = ''; readingListSummary: string = '';
libraryTypes: {[key: number]: LibraryType} = {}; libraryTypes: {[key: number]: LibraryType} = {};
@ -114,7 +113,6 @@ export class ReadingListDetailComponent implements OnInit {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => { this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) { if (user) {
this.isAdmin = this.accountService.hasAdminRole(user); this.isAdmin = this.accountService.hasAdminRole(user);
this.hasDownloadingRole = this.accountService.hasDownloadRole(user);
this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this))
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin)); .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin));

View File

@ -31,11 +31,7 @@
</h5> </h5>
<div class="ps-1 d-none d-md-inline-block"> <div class="ps-1 d-none d-md-inline-block">
<ng-container *ngIf="item.seriesFormat | mangaFormat as formatString"> <app-series-format [format]="item.seriesFormat"></app-series-format>
<i class="fa {{item.seriesFormat | mangaFormatIcon}}" aria-hidden="true" *ngIf="item.seriesFormat !== MangaFormat.UNKNOWN" title="{{formatString}}"></i>
<span class="visually-hidden">{{formatString}}</span>&nbsp;
</ng-container>
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a> <a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a>
</div> </div>

View File

@ -9,6 +9,7 @@ import { NgbProgressbar } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, DatePipe } from '@angular/common'; import { NgIf, DatePipe } from '@angular/common';
import { ImageComponent } from '../../../shared/image/image.component'; import { ImageComponent } from '../../../shared/image/image.component';
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component";
@Component({ @Component({
selector: 'app-reading-list-item', selector: 'app-reading-list-item',
@ -16,7 +17,7 @@ import {TranslocoDirective} from "@ngneat/transloco";
styleUrls: ['./reading-list-item.component.scss'], styleUrls: ['./reading-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [ImageComponent, NgIf, NgbProgressbar, DatePipe, MangaFormatPipe, MangaFormatIconPipe, TranslocoDirective] imports: [ImageComponent, NgIf, NgbProgressbar, DatePipe, MangaFormatPipe, MangaFormatIconPipe, TranslocoDirective, SeriesFormatComponent]
}) })
export class ReadingListItemComponent { export class ReadingListItemComponent {

View File

@ -20,6 +20,7 @@ export class MetadataDetailComponent {
private readonly filterUtilityService = inject(FilterUtilitiesService); private readonly filterUtilityService = inject(FilterUtilitiesService);
public readonly utilityService = inject(UtilityService); public readonly utilityService = inject(UtilityService);
protected readonly TagBadgeCursor = TagBadgeCursor; protected readonly TagBadgeCursor = TagBadgeCursor;
protected readonly Breakpoint = Breakpoint; protected readonly Breakpoint = Breakpoint;

View File

@ -35,13 +35,16 @@
</app-metadata-detail> </app-metadata-detail>
<!-- Collections --> <!-- Collections -->
<app-metadata-detail [tags]="seriesMetadata.collectionTags" [libraryId]="series.libraryId" [heading]="t('collections-title')"> @if (collections$) {
<ng-template #itemTemplate let-item> <app-metadata-detail [tags]="(collections$ | async)!" [libraryId]="series.libraryId" [heading]="t('collections-title')">
<app-tag-badge a11y-click="13,32" class="col-auto" (click)="navigate('collections', item.id)" [selectionMode]="TagBadgeCursor.Clickable"> <ng-template #itemTemplate let-item>
{{item.title}} <app-tag-badge a11y-click="13,32" class="col-auto" (click)="navigate('collections', item.id)" [selectionMode]="TagBadgeCursor.Clickable">
</app-tag-badge> <app-promoted-icon [promoted]="item.promoted"></app-promoted-icon> {{item.title}}
</ng-template> </app-tag-badge>
</app-metadata-detail> </ng-template>
</app-metadata-detail>
}
<!-- Reading Lists --> <!-- Reading Lists -->
<app-metadata-detail [tags]="readingLists" [libraryId]="series.libraryId" [heading]="t('reading-lists-title')"> <app-metadata-detail [tags]="readingLists" [libraryId]="series.libraryId" [heading]="t('reading-lists-title')">

View File

@ -1,7 +1,7 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component, DestroyRef,
inject, inject,
Input, Input,
OnChanges, OnInit, OnChanges, OnInit,
@ -33,6 +33,12 @@ import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {ImageComponent} from "../../../shared/image/image.component"; import {ImageComponent} from "../../../shared/image/image.component";
import {Rating} from "../../../_models/rating"; import {Rating} from "../../../_models/rating";
import {CollectionTagService} from "../../../_services/collection-tag.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {shareReplay} from "rxjs/operators";
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
import {Observable} from "rxjs";
import {UserCollection} from "../../../_models/collection-tag";
@Component({ @Component({
@ -40,7 +46,7 @@ import {Rating} from "../../../_models/rating";
standalone: true, standalone: true,
imports: [CommonModule, TagBadgeComponent, BadgeExpanderComponent, SafeHtmlPipe, ExternalRatingComponent, imports: [CommonModule, TagBadgeComponent, BadgeExpanderComponent, SafeHtmlPipe, ExternalRatingComponent,
ReadMoreComponent, A11yClickDirective, PersonBadgeComponent, NgbCollapse, SeriesInfoCardsComponent, ReadMoreComponent, A11yClickDirective, PersonBadgeComponent, NgbCollapse, SeriesInfoCardsComponent,
MetadataDetailComponent, TranslocoDirective, ImageComponent], MetadataDetailComponent, TranslocoDirective, ImageComponent, PromotedIconComponent],
templateUrl: './series-metadata-detail.component.html', templateUrl: './series-metadata-detail.component.html',
styleUrls: ['./series-metadata-detail.component.scss'], styleUrls: ['./series-metadata-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -53,6 +59,8 @@ export class SeriesMetadataDetailComponent implements OnChanges, OnInit {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly filterUtilityService = inject(FilterUtilitiesService); private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly collectionTagService = inject(CollectionTagService);
private readonly destroyRef = inject(DestroyRef);
protected readonly FilterField = FilterField; protected readonly FilterField = FilterField;
protected readonly LibraryType = LibraryType; protected readonly LibraryType = LibraryType;
@ -77,6 +85,7 @@ export class SeriesMetadataDetailComponent implements OnChanges, OnInit {
* Html representation of Series Summary * Html representation of Series Summary
*/ */
seriesSummary: string = ''; seriesSummary: string = '';
collections$: Observable<UserCollection[]> | undefined;
get WebLinks() { get WebLinks() {
if (this.seriesMetadata?.webLinks === '') return []; if (this.seriesMetadata?.webLinks === '') return [];
@ -96,7 +105,12 @@ export class SeriesMetadataDetailComponent implements OnChanges, OnInit {
if (sum > 10) { if (sum > 10) {
this.isCollapsed = true; this.isCollapsed = true;
} }
this.collections$ = this.collectionTagService.allCollectionsForSeries(this.series.id).pipe(
takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {

View File

@ -0,0 +1,8 @@
<ng-container *transloco="let t; read: 'promoted-icon'">
@if(promoted) {
<span>
<i class="fa fa-angle-double-up ms-1" aria-hidden="true" title="Promoted"></i>
<span class="visually-hidden">{{t('promoted')}}</span>
</span>
}
</ng-container>

View File

@ -0,0 +1,16 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {TranslocoDirective} from "@ngneat/transloco";
@Component({
selector: 'app-promoted-icon',
standalone: true,
imports: [
TranslocoDirective
],
templateUrl: './promoted-icon.component.html',
styleUrl: './promoted-icon.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PromotedIconComponent {
@Input({required: true}) promoted: boolean = false;
}

View File

@ -23,7 +23,7 @@ import {translate} from "@ngneat/transloco";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SAVER, Saver} from "../../_providers/saver.provider"; import {SAVER, Saver} from "../../_providers/saver.provider";
import {UtilityService} from "./utility.service"; import {UtilityService} from "./utility.service";
import {CollectionTag} from "../../_models/collection-tag"; import {UserCollection} from "../../_models/collection-tag";
import {RecentlyAddedItem} from "../../_models/recently-added-item"; import {RecentlyAddedItem} from "../../_models/recently-added-item";
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
@ -359,7 +359,7 @@ export class DownloadService {
} }
} }
mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem | NextExpectedChapter) { mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter) {
if(this.utilityService.isSeries(entity)) { if(this.utilityService.isSeries(entity)) {
return events.find(e => e.entityType === 'series' && e.id == entity.id return events.find(e => e.entityType === 'series' && e.id == entity.id
&& e.subTitle === this.downloadSubtitle('series', (entity as Series))) || null; && e.subTitle === this.downloadSubtitle('series', (entity as Series))) || null;

View File

@ -1,4 +1,4 @@
<ng-container *ngIf="format !== MangaFormat.UNKNOWN"> <ng-container *ngIf="format !== MangaFormat.UNKNOWN">
<i class="fa {{format | mangaFormatIcon}}" aria-hidden="true" title="{{format | mangaFormat}}"></i>&nbsp; <i class="{{format | mangaFormatIcon}} me-1" aria-hidden="true" title="{{format | mangaFormat}}"></i>
<ng-content></ng-content> <ng-content></ng-content>
</ng-container> </ng-container>

View File

@ -18,9 +18,7 @@ import {CommonModule} from "@angular/common";
}) })
export class SeriesFormatComponent { export class SeriesFormatComponent {
@Input() format: MangaFormat = MangaFormat.UNKNOWN; protected readonly MangaFormat = MangaFormat;
get MangaFormat(): typeof MangaFormat { @Input() format: MangaFormat = MangaFormat.UNKNOWN;
return MangaFormat;
}
} }

View File

@ -1381,9 +1381,9 @@
"promote-label": "Promote", "promote-label": "Promote",
"promote-tooltip": "Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.", "promote-tooltip": "Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.",
"summary-label": "Summary", "summary-label": "Summary",
"series-title": "Applies to Series",
"deselect-all": "{{common.deselect-all}}", "deselect-all": "{{common.deselect-all}}",
"select-all": "{{common.select-all}}" "select-all": "{{common.select-all}}",
"filter-label": "{{common.filter}}"
}, },
"library-detail": { "library-detail": {
@ -1527,7 +1527,7 @@
"skip-alt": "Skip to main content", "skip-alt": "Skip to main content",
"search-series-alt": "Search series", "search-series-alt": "Search series",
"search-alt": "Search…", "search-alt": "Search…",
"promoted": "(promoted)", "promoted": "{{common.promoted}}",
"no-data": "No results found", "no-data": "No results found",
"scroll-to-top-alt": "Scroll to Top", "scroll-to-top-alt": "Scroll to Top",
"server-settings": "Server Settings", "server-settings": "Server Settings",
@ -1538,11 +1538,20 @@
"all-filters": "Smart Filters" "all-filters": "Smart Filters"
}, },
"promoted-icon": {
"promoted": "{{common.promoted}}"
},
"collection-owner": {
"collection-created-label": "Created by: {{owner}}",
"collection-via-label": "via {{source}}"
},
"add-to-list-modal": { "add-to-list-modal": {
"title": "Add to Reading List", "title": "Add to Reading List",
"close": "{{common.close}}", "close": "{{common.close}}",
"filter-label": "{{common.filter}}", "filter-label": "{{common.filter}}",
"promoted-alt": "Promoted", "promoted-alt": "{{common.promoted}}",
"no-data": "No lists created yet", "no-data": "No lists created yet",
"loading": "{{common.loading}}", "loading": "{{common.loading}}",
"reading-list-label": "Reading List", "reading-list-label": "Reading List",
@ -1565,9 +1574,11 @@
"ending-title": "Ending", "ending-title": "Ending",
"starting-title": "Starting", "starting-title": "Starting",
"promote-label": "Promote", "promote-label": "Promote",
"promote-tooltip": "Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them." "promote-tooltip": "Promotion means that the collection can be seen server-wide, not just for you. All series within this collection will still have user-access restrictions placed on them."
}, },
"import-mal-collection-modal": { "import-mal-collection-modal": {
"close": "{{common.close}}", "close": "{{common.close}}",
"title": "MAL Interest Stack Import", "title": "MAL Interest Stack Import",
@ -1748,7 +1759,6 @@
"cover-image-tab": "Cover Image", "cover-image-tab": "Cover Image",
"related-tab": "Related", "related-tab": "Related",
"info-tab": "Info", "info-tab": "Info",
"collections-label": "Collections",
"genres-label": "Genres", "genres-label": "Genres",
"tags-label": "Tags", "tags-label": "Tags",
"cover-artist-label": "Cover Artist", "cover-artist-label": "Cover Artist",
@ -2162,7 +2172,13 @@
"external-source-already-exists": "An External Source already exists with the same Name/Host/API Key", "external-source-already-exists": "An External Source already exists with the same Name/Host/API Key",
"anilist-token-expired": "Your AniList token is expired. Scrobbling will no longer process until you re-generate it in User Settings > Account", "anilist-token-expired": "Your AniList token is expired. Scrobbling will no longer process until you re-generate it in User Settings > Account",
"collection-tag-deleted": "Collection Tag deleted", "collection-tag-deleted": "Collection Tag deleted",
"force-kavita+-refresh-success": "Kavita+ external metadata has been invalidated" "force-kavita+-refresh-success": "Kavita+ external metadata has been invalidated",
"collection-not-owned": "You do not own this collection",
"collections-promoted": "Collections promoted",
"collections-unpromoted": "Collections un-promoted",
"confirm-delete-collections": "Are you sure you want to delete multiple collections?",
"collections-deleted": "Collections deleted"
}, },
"actionable": { "actionable": {
@ -2196,7 +2212,9 @@
"remove-rule-group": "Remove Rule Group", "remove-rule-group": "Remove Rule Group",
"customize": "Customize", "customize": "Customize",
"mark-visible": "Mark as Visible", "mark-visible": "Mark as Visible",
"mark-invisible": "Mark as Invisible" "mark-invisible": "Mark as Invisible",
"unpromote": "Un-Promote",
"promote": "Promote"
}, },
"preferences": { "preferences": {

View File

@ -181,6 +181,7 @@
/* Rating star */ /* Rating star */
--ratingstar-color: white; --ratingstar-color: white;
--rating-star-color: var(--primary-color);
--ratingstar-star-empty: #b0c4de; --ratingstar-star-empty: #b0c4de;
--ratingstar-star-filled: var(--primary-color); --ratingstar-star-filled: var(--primary-color);
@ -257,9 +258,6 @@
--review-spoiler-bg-color: var(--primary-color); --review-spoiler-bg-color: var(--primary-color);
--review-spoiler-text-color: var(--body-text-color); --review-spoiler-text-color: var(--body-text-color);
/** Rating Star Color **/
--rating-star-color: var(--primary-color);
/** Badge **/ /** Badge **/
--badge-text-color: var(--bs-badge-color); --badge-text-color: var(--bs-badge-color);

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.7.14.10" "version": "0.7.14.11"
}, },
"servers": [ "servers": [
{ {
@ -1312,7 +1312,17 @@
"tags": [ "tags": [
"Collection" "Collection"
], ],
"summary": "Return a list of all collection tags on the server for the logged in user.", "summary": "Returns all Collection tags for a given User",
"parameters": [
{
"name": "ownedOnly",
"in": "query",
"schema": {
"type": "boolean",
"default": false
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "Success", "description": "Success",
@ -1321,7 +1331,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/CollectionTagDto" "$ref": "#/components/schemas/AppUserCollectionDto"
} }
} }
}, },
@ -1329,7 +1339,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/CollectionTagDto" "$ref": "#/components/schemas/AppUserCollectionDto"
} }
} }
}, },
@ -1337,7 +1347,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/CollectionTagDto" "$ref": "#/components/schemas/AppUserCollectionDto"
} }
} }
} }
@ -1349,7 +1359,7 @@
"tags": [ "tags": [
"Collection" "Collection"
], ],
"summary": "Removes the collection tag from all Series it was attached to", "summary": "Removes the collection tag from the user",
"parameters": [ "parameters": [
{ {
"name": "tagId", "name": "tagId",
@ -1368,19 +1378,29 @@
} }
} }
}, },
"/api/Collection/search": { "/api/Collection/all-series": {
"get": { "get": {
"tags": [ "tags": [
"Collection" "Collection"
], ],
"summary": "Searches against the collection tags on the DB and returns matches that meet the search criteria.\r\n<remarks>Search strings will be cleaned of certain fields, like %</remarks>", "summary": "Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned)",
"parameters": [ "parameters": [
{ {
"name": "queryString", "name": "seriesId",
"in": "query", "in": "query",
"description": "Search term", "description": "",
"schema": { "schema": {
"type": "string" "type": "integer",
"format": "int32"
}
},
{
"name": "ownedOnly",
"in": "query",
"description": "",
"schema": {
"type": "boolean",
"default": false
} }
} }
], ],
@ -1392,7 +1412,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/CollectionTagDto" "$ref": "#/components/schemas/AppUserCollectionDto"
} }
} }
}, },
@ -1400,7 +1420,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/CollectionTagDto" "$ref": "#/components/schemas/AppUserCollectionDto"
} }
} }
}, },
@ -1408,7 +1428,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/CollectionTagDto" "$ref": "#/components/schemas/AppUserCollectionDto"
} }
} }
} }
@ -1468,17 +1488,83 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CollectionTagDto" "$ref": "#/components/schemas/AppUserCollectionDto"
} }
}, },
"text/json": { "text/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CollectionTagDto" "$ref": "#/components/schemas/AppUserCollectionDto"
} }
}, },
"application/*+json": { "application/*+json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CollectionTagDto" "$ref": "#/components/schemas/AppUserCollectionDto"
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Collection/promote-multiple": {
"post": {
"tags": [
"Collection"
],
"summary": "Promote/UnPromote multiple collections in one go. Will only update the authenticated user's collections and will only work if the user has promotion role",
"requestBody": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PromoteCollectionsDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/PromoteCollectionsDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/PromoteCollectionsDto"
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Collection/delete-multiple": {
"post": {
"tags": [
"Collection"
],
"summary": "Promote/UnPromote multiple collections in one go",
"requestBody": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PromoteCollectionsDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/PromoteCollectionsDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/PromoteCollectionsDto"
} }
} }
} }
@ -1495,7 +1581,7 @@
"tags": [ "tags": [
"Collection" "Collection"
], ],
"summary": "Adds a collection tag onto multiple Series. If tag id is 0, this will create a new tag.", "summary": "Adds multiple series to a collection. If tag id is 0, this will create a new tag.",
"requestBody": { "requestBody": {
"description": "", "description": "",
"content": { "content": {
@ -2348,7 +2434,7 @@
"tags": [ "tags": [
"Image" "Image"
], ],
"summary": "Returns cover image for Collection Tag", "summary": "Returns cover image for Collection",
"parameters": [ "parameters": [
{ {
"name": "collectionTagId", "name": "collectionTagId",
@ -12442,6 +12528,7 @@
"WantToRead" "WantToRead"
], ],
"summary": "Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2)", "summary": "Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2)",
"description": "This will be removed in v0.8.x",
"parameters": [ "parameters": [
{ {
"name": "PageNumber", "name": "PageNumber",
@ -12927,6 +13014,14 @@
"description": "Reading lists associated with this user", "description": "Reading lists associated with this user",
"nullable": true "nullable": true
}, },
"collections": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AppUserCollection"
},
"description": "Collections associated with this user",
"nullable": true
},
"wantToRead": { "wantToRead": {
"type": "array", "type": "array",
"items": { "items": {
@ -13102,6 +13197,192 @@
"additionalProperties": false, "additionalProperties": false,
"description": "Represents a saved page in a Chapter entity for a given user." "description": "Represents a saved page in a Chapter entity for a given user."
}, },
"AppUserCollection": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"title": {
"type": "string",
"nullable": true
},
"normalizedTitle": {
"type": "string",
"description": "A normalized string used to check if the collection already exists in the DB",
"nullable": true
},
"summary": {
"type": "string",
"nullable": true
},
"promoted": {
"type": "boolean",
"description": "Reading lists that are promoted are only done by admins"
},
"coverImage": {
"type": "string",
"description": "Path to the (managed) image file",
"nullable": true
},
"coverImageLocked": {
"type": "boolean"
},
"ageRating": {
"enum": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
-1
],
"type": "integer",
"description": "The highest age rating from all Series within the collection",
"format": "int32"
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Series"
},
"nullable": true
},
"created": {
"type": "string",
"format": "date-time"
},
"lastModified": {
"type": "string",
"format": "date-time"
},
"createdUtc": {
"type": "string",
"format": "date-time"
},
"lastModifiedUtc": {
"type": "string",
"format": "date-time"
},
"lastSyncUtc": {
"type": "string",
"description": "Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)",
"format": "date-time"
},
"source": {
"enum": [
0,
1,
2
],
"type": "integer",
"description": "Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote",
"format": "int32"
},
"sourceUrl": {
"type": "string",
"description": "For Non-Kavita sourced collections, the url to sync from",
"nullable": true
},
"appUser": {
"$ref": "#/components/schemas/AppUser"
},
"appUserId": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false,
"description": "Represents a Collection of Series for a given User"
},
"AppUserCollectionDto": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"title": {
"type": "string",
"nullable": true
},
"summary": {
"type": "string",
"nullable": true
},
"promoted": {
"type": "boolean"
},
"ageRating": {
"enum": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
-1
],
"type": "integer",
"description": "Represents Age Rating for content.",
"format": "int32"
},
"coverImage": {
"type": "string",
"description": "This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.",
"nullable": true
},
"coverImageLocked": {
"type": "boolean"
},
"owner": {
"type": "string",
"description": "Owner of the Collection",
"nullable": true
},
"lastSyncUtc": {
"type": "string",
"description": "Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)",
"format": "date-time"
},
"source": {
"enum": [
0,
1,
2
],
"type": "integer",
"description": "Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote",
"format": "int32"
},
"sourceUrl": {
"type": "string",
"description": "For Non-Kavita sourced collections, the url to sync from",
"nullable": true
}
},
"additionalProperties": false
},
"AppUserDashboardStream": { "AppUserDashboardStream": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -17124,6 +17405,23 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"PromoteCollectionsDto": {
"type": "object",
"properties": {
"collectionIds": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
},
"nullable": true
},
"promoted": {
"type": "boolean"
}
},
"additionalProperties": false
},
"PublicationStatusStatCount": { "PublicationStatusStatCount": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -18084,7 +18382,7 @@
"collections": { "collections": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/CollectionTagDto" "$ref": "#/components/schemas/AppUserCollectionDto"
}, },
"nullable": true "nullable": true
}, },
@ -18326,6 +18624,13 @@
}, },
"nullable": true "nullable": true
}, },
"collections": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AppUserCollection"
},
"nullable": true
},
"relations": { "relations": {
"type": "array", "type": "array",
"items": { "items": {
@ -18591,7 +18896,8 @@
"items": { "items": {
"$ref": "#/components/schemas/CollectionTag" "$ref": "#/components/schemas/CollectionTag"
}, },
"nullable": true "nullable": true,
"deprecated": true
}, },
"genres": { "genres": {
"type": "array", "type": "array",
@ -18762,14 +19068,6 @@
"type": "string", "type": "string",
"nullable": true "nullable": true
}, },
"collectionTags": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CollectionTagDto"
},
"description": "Collections the Series belongs to",
"nullable": true
},
"genres": { "genres": {
"type": "array", "type": "array",
"items": { "items": {
@ -20435,13 +20733,6 @@
"properties": { "properties": {
"seriesMetadata": { "seriesMetadata": {
"$ref": "#/components/schemas/SeriesMetadataDto" "$ref": "#/components/schemas/SeriesMetadataDto"
},
"collectionTags": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CollectionTagDto"
},
"nullable": true
} }
}, },
"additionalProperties": false "additionalProperties": false