mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Collection Rework (#2830)
This commit is contained in:
parent
0dacc061f1
commit
deaaccb96a
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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());
|
||||||
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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))
|
||||||
{
|
{
|
||||||
|
@ -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");
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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)}");
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
39
API/DTOs/Collection/AppUserCollectionDto.cs
Normal file
39
API/DTOs/Collection/AppUserCollectionDto.cs
Normal 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; }
|
||||||
|
}
|
8
API/DTOs/Collection/DeleteCollectionsDto.cs
Normal file
8
API/DTOs/Collection/DeleteCollectionsDto.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace API.DTOs.Collection;
|
||||||
|
|
||||||
|
public class DeleteCollectionsDto
|
||||||
|
{
|
||||||
|
public IList<int> CollectionIds { get; set; }
|
||||||
|
}
|
9
API/DTOs/Collection/PromoteCollectionsDto.cs
Normal file
9
API/DTOs/Collection/PromoteCollectionsDto.cs
Normal 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; }
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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!;
|
||||||
|
@ -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>
|
||||||
|
@ -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!;
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
3019
API/Data/Migrations/20240331172900_UserBasedCollections.Designer.cs
generated
Normal file
3019
API/Data/Migrations/20240331172900_UserBasedCollections.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
92
API/Data/Migrations/20240331172900_UserBasedCollections.cs
Normal file
92
API/Data/Migrations/20240331172900_UserBasedCollections.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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");
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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!;
|
||||||
|
60
API/Entities/AppUserCollection.cs
Normal file
60
API/Entities/AppUserCollection.cs
Normal 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; }
|
||||||
|
}
|
@ -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
|
||||||
{
|
{
|
||||||
|
@ -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>();
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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:
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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(
|
||||||
|
72
API/Helpers/Builders/AppUserCollectionBuilder.cs
Normal file
72
API/Helpers/Builders/AppUserCollectionBuilder.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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(),
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -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>;
|
||||||
|
@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
@ -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: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -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' });
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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>) {
|
||||||
|
@ -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">
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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' : ''}}">
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
// });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
@ -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>
|
@ -0,0 +1,4 @@
|
|||||||
|
.text-accent {
|
||||||
|
font-size: small;
|
||||||
|
color: var(---accent-text-color);
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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);
|
||||||
|
@ -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>
|
||||||
<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">
|
||||||
<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
|
<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>
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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));
|
||||||
|
@ -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>
|
|
||||||
</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>
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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')">
|
||||||
|
@ -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 {
|
||||||
|
@ -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>
|
@ -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;
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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>
|
<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>
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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": {
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
363
openapi.json
363
openapi.json
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user