Automatic Collection Creation (#1768)

* Made the unread badges slightly smaller and rounded on top right.

* A bit more tweaks on the not read badges. Looking really nice now.

* In order to start the work on managing collections from ScanLoop, I needed to refactor collection apis into the service layer and add unit tests.

Removed ToUpper Normalization for new tags.

* Hooked up ability to auto generate collections from SeriesGroup metadata tag.
This commit is contained in:
Joe Milazzo 2023-01-30 19:57:46 -08:00 committed by GitHub
parent 91a2a6854f
commit 1da27f085c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 2222 additions and 121 deletions

View File

@ -56,7 +56,7 @@ public abstract class AbstractDbTest
return connection; return connection;
} }
protected async Task<bool> SeedDb() private async Task<bool> SeedDb()
{ {
await _context.Database.MigrateAsync(); await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem(); var filesystem = CreateFileSystem();
@ -86,7 +86,8 @@ public abstract class AbstractDbTest
{ {
Path = "C:/data/" Path = "C:/data/"
} }
} },
Series = new List<Series>()
}); });
return await _context.SaveChangesAsync() > 0; return await _context.SaveChangesAsync() > 0;
} }

View File

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using API.Data;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Metadata; using API.Entities.Metadata;
@ -70,13 +71,6 @@ public static class EntityFactory
public static CollectionTag CreateCollectionTag(int id, string title, string summary, bool promoted) public static CollectionTag CreateCollectionTag(int id, string title, string summary, bool promoted)
{ {
return new CollectionTag() return DbFactory.CollectionTag(id, title, summary, promoted);
{
Id = id,
NormalizedTitle = API.Services.Tasks.Scanner.Parser.Parser.Normalize(title).ToUpper(),
Title = title,
Summary = summary,
Promoted = promoted
};
} }
} }

View File

@ -0,0 +1,113 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.CollectionTags;
using API.Entities;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Metadata;
using API.SignalR;
using API.Tests.Helpers;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class CollectionTagServiceTests : AbstractDbTest
{
private readonly ICollectionTagService _service;
public CollectionTagServiceTests()
{
_service = new CollectionTagService(_unitOfWork, Substitute.For<IEventHub>());
}
protected override async Task ResetDb()
{
_context.CollectionTag.RemoveRange(_context.CollectionTag.ToList());
_context.Library.RemoveRange(_context.Library.ToList());
await _unitOfWork.CommitAsync();
}
private async Task SeedSeries()
{
if (_context.CollectionTag.Any()) return;
_context.Library.Add(new Library()
{
Name = "Library 2",
Type = LibraryType.Manga,
Series = new List<Series>()
{
EntityFactory.CreateSeries("Series 1"),
EntityFactory.CreateSeries("Series 2"),
}
});
_context.CollectionTag.Add(DbFactory.CollectionTag(0, "Tag 1", string.Empty, false));
_context.CollectionTag.Add(DbFactory.CollectionTag(0, "Tag 2", string.Empty, true));
await _unitOfWork.CommitAsync();
}
[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]
public async Task UpdateTag_ShouldUpdateFields()
{
await SeedSeries();
_context.CollectionTag.Add(EntityFactory.CreateCollectionTag(3, "UpdateTag_ShouldUpdateFields",
string.Empty, true));
await _unitOfWork.CommitAsync();
await _service.UpdateTag(new CollectionTagDto()
{
Title = "UpdateTag_ShouldUpdateFields",
Id = 3,
Promoted = true,
Summary = "Test Summary",
});
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(3);
Assert.NotNull(tag);
Assert.True(tag.Promoted);
Assert.True(!string.IsNullOrEmpty(tag.Summary));
}
[Fact]
public async Task AddTagToSeries_ShouldAddTagToAllSeries()
{
await SeedSeries();
var ids = new[] {1, 2};
await _service.AddTagToSeries(await _unitOfWork.CollectionTagRepository.GetFullTagAsync(1), ids);
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(ids);
Assert.True(metadatas.ElementAt(0).CollectionTags.Any(t => t.Title.Equals("Tag 1")));
Assert.True(metadatas.ElementAt(1).CollectionTags.Any(t => t.Title.Equals("Tag 1")));
}
[Fact]
public async Task RemoveTagFromSeries_ShouldRemoveMultiple()
{
await SeedSeries();
var ids = new[] {1, 2};
var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(2);
await _service.AddTagToSeries(tag, ids);
await _service.RemoveTagFromSeries(tag, new[] {1});
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1});
Assert.Single(metadatas);
Assert.Empty(metadatas.First().CollectionTags);
Assert.NotEmpty(await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {2}));
}
}

View File

@ -51,7 +51,6 @@ public class DeviceServiceDbTests : AbstractDbTest
}, user); }, user);
Assert.NotNull(device); Assert.NotNull(device);
} }
[Fact] [Fact]

View File

@ -6,7 +6,10 @@ using API.Data;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.Entities.Metadata; using API.Entities.Metadata;
using API.Extensions; using API.Extensions;
using API.Services;
using API.Services.Tasks.Metadata;
using API.SignalR; using API.SignalR;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -18,13 +21,13 @@ namespace API.Controllers;
public class CollectionController : BaseApiController public class CollectionController : BaseApiController
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub; private readonly ICollectionTagService _collectionService;
/// <inheritdoc /> /// <inheritdoc />
public CollectionController(IUnitOfWork unitOfWork, IEventHub eventHub) public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_eventHub = eventHub; _collectionService = collectionService;
} }
/// <summary> /// <summary>
@ -71,8 +74,7 @@ public class CollectionController : BaseApiController
[HttpGet("name-exists")] [HttpGet("name-exists")]
public async Task<ActionResult<bool>> DoesNameExists(string name) public async Task<ActionResult<bool>> DoesNameExists(string name)
{ {
if (string.IsNullOrEmpty(name.Trim())) return Ok(true); return Ok(await _collectionService.TagExistsByName(name));
return Ok(await _unitOfWork.CollectionTagRepository.TagExists(name));
} }
/// <summary> /// <summary>
@ -85,28 +87,13 @@ public class CollectionController : BaseApiController
[HttpPost("update")] [HttpPost("update")]
public async Task<ActionResult> UpdateTag(CollectionTagDto updatedTag) public async Task<ActionResult> UpdateTag(CollectionTagDto updatedTag)
{ {
var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updatedTag.Id); try
if (existingTag == null) return BadRequest("This tag does not exist");
var title = updatedTag.Title.Trim();
if (string.IsNullOrEmpty(title)) return BadRequest("Title cannot be empty");
if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.TagExists(updatedTag.Title))
return BadRequest("A tag with this name already exists");
existingTag.Title = title;
existingTag.Promoted = updatedTag.Promoted;
existingTag.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(updatedTag.Title);
existingTag.Summary = updatedTag.Summary.Trim();
if (_unitOfWork.HasChanges())
{ {
if (await _unitOfWork.CommitAsync()) if (await _collectionService.UpdateTag(updatedTag)) return Ok("Tag updated successfully");
{
return Ok("Tag updated successfully");
}
} }
else catch (KavitaException ex)
{ {
return Ok("Tag updated successfully"); return BadRequest(ex.Message);
} }
return BadRequest("Something went wrong, please try again"); return BadRequest("Something went wrong, please try again");
@ -121,29 +108,11 @@ public class CollectionController : BaseApiController
[HttpPost("update-for-series")] [HttpPost("update-for-series")]
public async Task<ActionResult> AddToMultipleSeries(CollectionTagBulkAddDto dto) public async Task<ActionResult> AddToMultipleSeries(CollectionTagBulkAddDto dto)
{ {
var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(dto.CollectionTagId); // Create a new tag and save
if (tag == null) var tag = await _collectionService.GetTagOrCreate(dto.CollectionTagId, dto.CollectionTagTitle);
{
tag = DbFactory.CollectionTag(0, dto.CollectionTagTitle, String.Empty, false);
_unitOfWork.CollectionTagRepository.Add(tag);
}
if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok();
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(dto.SeriesIds);
foreach (var metadata in seriesMetadatas)
{
if (!metadata.CollectionTags.Any(t => t.Title.Equals(tag.Title, StringComparison.InvariantCulture)))
{
metadata.CollectionTags.Add(tag);
_unitOfWork.SeriesMetadataRepository.Update(metadata);
}
}
if (!_unitOfWork.HasChanges()) return Ok();
if (await _unitOfWork.CommitAsync())
{
return Ok();
}
return BadRequest("There was an issue updating series with collection tag"); return BadRequest("There was an issue updating series with collection tag");
} }
@ -154,7 +123,7 @@ public class CollectionController : BaseApiController
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-series")] [HttpPost("update-series")]
public async Task<ActionResult> UpdateSeriesForTag(UpdateSeriesForTagDto updateSeriesForTagDto) public async Task<ActionResult> RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto)
{ {
try try
{ {
@ -162,41 +131,8 @@ public class CollectionController : BaseApiController
if (tag == null) return BadRequest("Not a valid Tag"); if (tag == null) return BadRequest("Not a valid Tag");
tag.SeriesMetadatas ??= new List<SeriesMetadata>(); tag.SeriesMetadatas ??= new List<SeriesMetadata>();
// Check if Tag has updated (Summary) if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove))
if (tag.Summary == null || !tag.Summary.Equals(updateSeriesForTagDto.Tag.Summary))
{
tag.Summary = updateSeriesForTagDto.Tag.Summary;
_unitOfWork.CollectionTagRepository.Update(tag);
}
tag.CoverImageLocked = updateSeriesForTagDto.Tag.CoverImageLocked;
if (!updateSeriesForTagDto.Tag.CoverImageLocked)
{
tag.CoverImageLocked = false;
tag.CoverImage = string.Empty;
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(tag.Id, MessageFactoryEntityTypes.CollectionTag), false);
_unitOfWork.CollectionTagRepository.Update(tag);
}
foreach (var seriesIdToRemove in updateSeriesForTagDto.SeriesIdsToRemove)
{
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));
}
if (tag.SeriesMetadatas.Count == 0)
{
_unitOfWork.CollectionTagRepository.Remove(tag);
}
if (!_unitOfWork.HasChanges()) return Ok("No updates");
if (await _unitOfWork.CommitAsync())
{
return Ok("Tag updated"); return Ok("Tag updated");
}
} }
catch (Exception) catch (Exception)
{ {

View File

@ -247,11 +247,9 @@ public class LibraryController : BaseApiController
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None);
if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId)) if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId))
{ {
// TODO: Figure out how to cancel a job
_logger.LogInformation("User is attempting to delete a library while a scan is in progress"); _logger.LogInformation("User is attempting to delete a library while a scan is in progress");
return BadRequest( return BadRequest(
"You cannot delete a library while a scan is in progress. Please wait for scan to continue then try to delete"); "You cannot delete a library while a scan is in progress. Please wait for scan to complete or restart Kavita then try to delete");
} }
// Due to a bad schema that I can't figure out how to fix, we need to erase all RelatedSeries before we delete the library // Due to a bad schema that I can't figure out how to fix, we need to erase all RelatedSeries before we delete the library
@ -336,6 +334,7 @@ public class LibraryController : BaseApiController
library.IncludeInDashboard = dto.IncludeInDashboard; library.IncludeInDashboard = dto.IncludeInDashboard;
library.IncludeInRecommended = dto.IncludeInRecommended; library.IncludeInRecommended = dto.IncludeInRecommended;
library.IncludeInSearch = dto.IncludeInSearch; library.IncludeInSearch = dto.IncludeInSearch;
library.ManageCollections = dto.CreateCollections;
_unitOfWork.LibraryRepository.Update(library); _unitOfWork.LibraryRepository.Update(library);

View File

@ -30,6 +30,10 @@ public class LibraryDto
/// </summary> /// </summary>
public bool IncludeInRecommended { get; set; } = true; public bool IncludeInRecommended { get; set; } = true;
/// <summary> /// <summary>
/// Should this library create and manage collections from Metadata
/// </summary>
public bool ManageCollections { get; set; } = true;
/// <summary>
/// Include library series in Search /// Include library series in Search
/// </summary> /// </summary>
public bool IncludeInSearch { get; set; } = true; public bool IncludeInSearch { get; set; } = true;

View File

@ -1,17 +1,28 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using API.Entities.Enums; using API.Entities.Enums;
namespace API.DTOs; namespace API.DTOs;
public class UpdateLibraryDto public class UpdateLibraryDto
{ {
[Required]
public int Id { get; init; } public int Id { get; init; }
[Required]
public string Name { get; init; } public string Name { get; init; }
[Required]
public LibraryType Type { get; set; } public LibraryType Type { get; set; }
[Required]
public IEnumerable<string> Folders { get; init; } public IEnumerable<string> Folders { get; init; }
[Required]
public bool FolderWatching { get; init; } public bool FolderWatching { get; init; }
[Required]
public bool IncludeInDashboard { get; init; } public bool IncludeInDashboard { get; init; }
[Required]
public bool IncludeInRecommended { get; init; } public bool IncludeInRecommended { get; init; }
[Required]
public bool IncludeInSearch { get; init; } public bool IncludeInSearch { get; init; }
[Required]
public bool CreateCollections { get; init; }
} }

View File

@ -104,6 +104,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<Library>() builder.Entity<Library>()
.Property(b => b.IncludeInSearch) .Property(b => b.IncludeInSearch)
.HasDefaultValue(true); .HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.ManageCollections)
.HasDefaultValue(true);
} }

View File

@ -95,7 +95,7 @@ public static class DbFactory
return new CollectionTag() return new CollectionTag()
{ {
Id = id, Id = id,
NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()).ToUpper(), NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()),
Title = title?.Trim(), Title = title?.Trim(),
Summary = summary?.Trim(), Summary = summary?.Trim(),
Promoted = promoted Promoted = promoted
@ -106,7 +106,7 @@ public static class DbFactory
{ {
return new ReadingList() return new ReadingList()
{ {
NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()).ToUpper(), NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()),
Title = title?.Trim(), Title = title?.Trim(),
Summary = summary?.Trim(), Summary = summary?.Trim(),
Promoted = promoted, Promoted = promoted,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class AutoCollections : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ManageCollections",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<string>(
name: "SeriesGroup",
table: "Chapter",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ManageCollections",
table: "Library");
migrationBuilder.DropColumn(
name: "SeriesGroup",
table: "Chapter");
}
}
}

View File

@ -400,6 +400,9 @@ namespace API.Data.Migrations
b.Property<DateTime>("ReleaseDate") b.Property<DateTime>("ReleaseDate")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("SeriesGroup")
.HasColumnType("TEXT");
b.Property<string>("Summary") b.Property<string>("Summary")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -580,6 +583,11 @@ namespace API.Data.Migrations
b.Property<DateTime>("LastScanned") b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("ManageCollections")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("TEXT");

View File

@ -73,6 +73,10 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
/// Number of the Total Count (progress the Series is complete) /// Number of the Total Count (progress the Series is complete)
/// </summary> /// </summary>
public int Count { get; set; } = 0; public int Count { get; set; } = 0;
/// <summary>
/// SeriesGroup tag in ComicInfo
/// </summary>
public string SeriesGroup { get; set; }
/// <summary> /// <summary>
/// Total Word count of all chapters in this chapter. /// Total Word count of all chapters in this chapter.

View File

@ -27,6 +27,10 @@ public class Library : IEntityDate
/// Include library series in Search /// Include library series in Search
/// </summary> /// </summary>
public bool IncludeInSearch { get; set; } = true; public bool IncludeInSearch { get; set; } = true;
/// <summary>
/// Should this library create and manage collections from Metadata
/// </summary>
public bool ManageCollections { get; set; } = true;
public DateTime Created { get; set; } public DateTime Created { get; set; }
public DateTime LastModified { get; set; } public DateTime LastModified { get; set; }
/// <summary> /// <summary>

View File

@ -55,6 +55,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IWordCountAnalyzerService, WordCountAnalyzerService>(); services.AddScoped<IWordCountAnalyzerService, WordCountAnalyzerService>();
services.AddScoped<ILibraryWatcher, LibraryWatcher>(); services.AddScoped<ILibraryWatcher, LibraryWatcher>();
services.AddScoped<ITachiyomiService, TachiyomiService>(); services.AddScoped<ITachiyomiService, TachiyomiService>();
services.AddScoped<ICollectionTagService, CollectionTagService>();
services.AddScoped<IPresenceTracker, PresenceTracker>(); services.AddScoped<IPresenceTracker, PresenceTracker>();
services.AddScoped<IEventHub, EventHub>(); services.AddScoped<IEventHub, EventHub>();

View File

@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.CollectionTags;
using API.Entities;
using API.Entities.Metadata;
using API.SignalR;
using Kavita.Common;
using Microsoft.Extensions.Logging;
namespace API.Services;
public interface ICollectionTagService
{
Task<bool> TagExistsByName(string name);
Task<bool> UpdateTag(CollectionTagDto dto);
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);
}
public class CollectionTagService : ICollectionTagService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub;
public CollectionTagService(IUnitOfWork unitOfWork, IEventHub eventHub)
{
_unitOfWork = unitOfWork;
_eventHub = eventHub;
}
/// <summary>
/// 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;
return await _unitOfWork.CollectionTagRepository.TagExists(name);
}
public async Task<bool> UpdateTag(CollectionTagDto dto)
{
var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(dto.Id);
if (existingTag == null) throw new KavitaException("This tag does not exist");
var title = dto.Title.Trim();
if (string.IsNullOrEmpty(title)) throw new KavitaException("Title cannot be empty");
if (!title.Equals(existingTag.Title) && await TagExistsByName(dto.Title))
throw new KavitaException("A tag with this name already exists");
existingTag.SeriesMetadatas ??= new List<SeriesMetadata>();
existingTag.Title = title;
existingTag.NormalizedTitle = Tasks.Scanner.Parser.Parser.Normalize(dto.Title);
existingTag.Promoted = dto.Promoted;
existingTag.CoverImageLocked = dto.CoverImageLocked;
_unitOfWork.CollectionTagRepository.Update(existingTag);
// Check if Tag has updated (Summary)
var summary = dto.Summary.Trim();
if (existingTag.Summary == null || !existingTag.Summary.Equals(summary))
{
existingTag.Summary = summary;
_unitOfWork.CollectionTagRepository.Update(existingTag);
}
// If we unlock the cover image it means reset
if (!dto.CoverImageLocked)
{
existingTag.CoverImageLocked = false;
existingTag.CoverImage = string.Empty;
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(existingTag.Id, MessageFactoryEntityTypes.CollectionTag), false);
_unitOfWork.CollectionTagRepository.Update(existingTag);
}
if (!_unitOfWork.HasChanges()) return true;
return await _unitOfWork.CommitAsync();
}
/// <summary>
/// Adds a set of Series to a Collection
/// </summary>
/// <param name="tag">A full Tag</param>
/// <param name="seriesIds"></param>
/// <returns></returns>
public async Task<bool> AddTagToSeries(CollectionTag tag, IEnumerable<int> seriesIds)
{
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(seriesIds);
foreach (var metadata in metadatas)
{
AddTagToSeriesMetadata(tag, metadata);
}
if (!_unitOfWork.HasChanges()) return true;
return await _unitOfWork.CommitAsync();
}
/// <summary>
/// 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)
{
metadata.CollectionTags ??= new List<CollectionTag>();
if (metadata.CollectionTags.Any(t => t.Title.Equals(tag.Title, StringComparison.InvariantCulture))) return;
metadata.CollectionTags.Add(tag);
_unitOfWork.SeriesMetadataRepository.Update(metadata);
}
public async Task<bool> RemoveTagFromSeries(CollectionTag tag, IEnumerable<int> seriesIds)
{
foreach (var seriesIdToRemove in seriesIds)
{
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));
}
if (tag.SeriesMetadatas.Count == 0)
{
_unitOfWork.CollectionTagRepository.Remove(tag);
}
if (!_unitOfWork.HasChanges()) return true;
return await _unitOfWork.CommitAsync();
}
/// <summary>
/// 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)
{
var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(tagId);
if (tag == null)
{
tag = DbFactory.CollectionTag(0, title, string.Empty, false);
_unitOfWork.CollectionTagRepository.Add(tag);
}
return tag;
}
}

View File

@ -45,6 +45,7 @@ public class ProcessSeries : IProcessSeries
private readonly IFileService _fileService; private readonly IFileService _fileService;
private readonly IMetadataService _metadataService; private readonly IMetadataService _metadataService;
private readonly IWordCountAnalyzerService _wordCountAnalyzerService; private readonly IWordCountAnalyzerService _wordCountAnalyzerService;
private readonly ICollectionTagService _collectionTagService;
private IList<Genre> _genres; private IList<Genre> _genres;
private IList<Person> _people; private IList<Person> _people;
@ -52,7 +53,8 @@ public class ProcessSeries : IProcessSeries
public ProcessSeries(IUnitOfWork unitOfWork, ILogger<ProcessSeries> logger, IEventHub eventHub, public ProcessSeries(IUnitOfWork unitOfWork, ILogger<ProcessSeries> logger, IEventHub eventHub,
IDirectoryService directoryService, ICacheHelper cacheHelper, IReadingItemService readingItemService, IDirectoryService directoryService, ICacheHelper cacheHelper, IReadingItemService readingItemService,
IFileService fileService, IMetadataService metadataService, IWordCountAnalyzerService wordCountAnalyzerService) IFileService fileService, IMetadataService metadataService, IWordCountAnalyzerService wordCountAnalyzerService,
ICollectionTagService collectionTagService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_logger = logger; _logger = logger;
@ -63,6 +65,7 @@ public class ProcessSeries : IProcessSeries
_fileService = fileService; _fileService = fileService;
_metadataService = metadataService; _metadataService = metadataService;
_wordCountAnalyzerService = wordCountAnalyzerService; _wordCountAnalyzerService = wordCountAnalyzerService;
_collectionTagService = collectionTagService;
} }
/// <summary> /// <summary>
@ -151,7 +154,7 @@ public class ProcessSeries : IProcessSeries
series.NormalizedLocalizedName = Parser.Parser.Normalize(series.LocalizedName); series.NormalizedLocalizedName = Parser.Parser.Normalize(series.LocalizedName);
} }
UpdateSeriesMetadata(series, library.Type); await UpdateSeriesMetadata(series, library);
// Update series FolderPath here // Update series FolderPath here
await UpdateSeriesFolderPath(parsedInfos, library, series); await UpdateSeriesFolderPath(parsedInfos, library, series);
@ -223,10 +226,10 @@ public class ProcessSeries : IProcessSeries
BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(libraryId, seriesId, forceUpdate)); BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(libraryId, seriesId, forceUpdate));
} }
private static void UpdateSeriesMetadata(Series series, LibraryType libraryType) private async Task UpdateSeriesMetadata(Series series, Library library)
{ {
series.Metadata ??= DbFactory.SeriesMetadata(new List<CollectionTag>()); series.Metadata ??= DbFactory.SeriesMetadata(new List<CollectionTag>());
var isBook = libraryType == LibraryType.Book; var isBook = library.Type == LibraryType.Book;
var firstChapter = SeriesService.GetFirstChapterForMetadata(series, isBook); var firstChapter = SeriesService.GetFirstChapterForMetadata(series, isBook);
var firstFile = firstChapter?.Files.FirstOrDefault(); var firstFile = firstChapter?.Files.FirstOrDefault();
@ -278,6 +281,14 @@ public class ProcessSeries : IProcessSeries
series.Metadata.Language = firstChapter.Language; series.Metadata.Language = firstChapter.Language;
} }
if (!string.IsNullOrEmpty(firstChapter.SeriesGroup) && library.ManageCollections)
{
_logger.LogDebug("Collection tag found for {SeriesName}", series.Name);
var tag = await _collectionTagService.GetTagOrCreate(0, firstChapter.SeriesGroup);
_collectionTagService.AddTagToSeriesMetadata(tag, series.Metadata);
}
// Handle People // Handle People
foreach (var chapter in chapters) foreach (var chapter in chapters)
{ {
@ -629,6 +640,11 @@ public class ProcessSeries : IProcessSeries
chapter.Language = comicInfo.LanguageISO; chapter.Language = comicInfo.LanguageISO;
} }
if (!string.IsNullOrEmpty(comicInfo.SeriesGroup))
{
chapter.SeriesGroup = comicInfo.SeriesGroup;
}
if (comicInfo.Count > 0) if (comicInfo.Count > 0)
{ {
chapter.TotalCount = comicInfo.Count; chapter.TotalCount = comicInfo.Count;

View File

@ -15,4 +15,5 @@ export interface Library {
includeInDashboard: boolean; includeInDashboard: boolean;
includeInRecommended: boolean; includeInRecommended: boolean;
includeInSearch: boolean; includeInSearch: boolean;
manageCollections: boolean;
} }

View File

@ -151,16 +151,15 @@ export class EditCollectionTagsComponent implements OnInit, OnDestroy {
async save() { async save() {
const selectedIndex = this.collectionTagForm.get('coverImageIndex')?.value || 0; const selectedIndex = this.collectionTagForm.get('coverImageIndex')?.value || 0;
const unselectedIds = this.selections.unselected().map(s => s.id); const unselectedIds = this.selections.unselected().map(s => s.id);
const tag: CollectionTag = {...this.tag}; const tag = this.collectionTagForm.value;
tag.summary = this.collectionTagForm.get('summary')?.value; tag.id = this.tag.id;
tag.coverImageLocked = this.collectionTagForm.get('coverImageLocked')?.value;
tag.promoted = this.collectionTagForm.get('promoted')?.value;
if (unselectedIds.length == this.series.length && !await this.confirmSerivce.confirm('Warning! No series are selected, saving will delete the tag. Are you sure you want to continue?')) { if (unselectedIds.length == this.series.length && !await this.confirmSerivce.confirm('Warning! No series are selected, saving will delete the tag. Are you sure you want to continue?')) {
return; return;
} }
const apis = [this.collectionService.updateTag(tag), const apis = [
this.collectionService.updateTag(tag),
this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id)) this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id))
]; ];

View File

@ -17,7 +17,12 @@
Cannot Read Cannot Read
</div> </div>
<div class="not-read-badge" *ngIf="read === 0 && total > 0"></div> <ng-container *ngIf="read === 0 && total > 0">
<div class="badge-container">
<div class="not-read-badge" ></div>
</div>
</ng-container>
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)" *ngIf="allowSelection"> <div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)" *ngIf="allowSelection">
<input type="checkbox" class="form-check-input" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}"> <input type="checkbox" class="form-check-input" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
</div> </div>

View File

@ -67,15 +67,28 @@ $image-width: 160px;
right: 30%; right: 30%;
} }
.badge-container {
border-radius: 4px;
display: block;
height: $image-height;
left: 0;
overflow: hidden;
pointer-events: none;
position: absolute;
top: 0;
width: 158px;
}
.not-read-badge { .not-read-badge {
position: absolute; position: absolute;
top: 0px; top: calc(-1 * (var(--card-progress-triangle-size) / 2));
right: 0px; right: -14px;
width: 0; z-index: 1000;
height: 0; height: var(--card-progress-triangle-size);
border-style: solid; width: var(--card-progress-triangle-size);
border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0; background-color: var(--primary-color);
border-color: transparent var(--primary-color) transparent transparent; transform: rotate(45deg);
} }
@ -109,8 +122,8 @@ $image-width: 160px;
.overlay { .overlay {
height: $image-height; height: $image-height;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
&:hover { &:hover {
visibility: visible; visibility: visible;
@ -169,4 +182,6 @@ $image-width: 160px;
height: $image-height; height: $image-height;
z-index: 10; z-index: 10;
transition: all 0.2s; transition: all 0.2s;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
} }

View File

@ -92,6 +92,19 @@
<li [ngbNavItem]="TabID.Advanced" [disabled]="isAddLibrary && setupStep < 3"> <li [ngbNavItem]="TabID.Advanced" [disabled]="isAddLibrary && setupStep < 3">
<a ngbNavLink>{{TabID.Advanced}}</a> <a ngbNavLink>{{TabID.Advanced}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="row">
<div class="col-md-12 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="manage-collections" role="switch" formControlName="manageCollections" class="form-check-input" aria-labelledby="auto-close-label">
<label class="form-check-label" for="manage-collections">Manage Collections</label>
</div>
</div>
<p class="accent">
Should Kavita create and update Collections from SeriesGroup tags found within ComicInfo.xml files
</p>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-md-12 col-sm-12 pe-2 mb-2"> <div class="col-md-12 col-sm-12 pe-2 mb-2">

View File

@ -46,6 +46,7 @@ export class LibrarySettingsModalComponent implements OnInit, OnDestroy {
includeInDashboard: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }), includeInDashboard: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInRecommended: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }), includeInRecommended: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInSearch: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }), includeInSearch: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
manageCollections: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
}); });
selectedFolders: string[] = []; selectedFolders: string[] = [];
@ -117,6 +118,7 @@ export class LibrarySettingsModalComponent implements OnInit, OnDestroy {
this.libraryForm.get('includeInDashboard')?.setValue(this.library.includeInDashboard); this.libraryForm.get('includeInDashboard')?.setValue(this.library.includeInDashboard);
this.libraryForm.get('includeInRecommended')?.setValue(this.library.includeInRecommended); this.libraryForm.get('includeInRecommended')?.setValue(this.library.includeInRecommended);
this.libraryForm.get('includeInSearch')?.setValue(this.library.includeInSearch); this.libraryForm.get('includeInSearch')?.setValue(this.library.includeInSearch);
this.libraryForm.get('manageCollections')?.setValue(this.library.manageCollections);
this.selectedFolders = this.library.folders; this.selectedFolders = this.library.folders;
this.madeChanges = false; this.madeChanges = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();

View File

@ -201,7 +201,7 @@
--card-progress-bar-color: var(--primary-color); --card-progress-bar-color: var(--primary-color);
--card-overlay-bg-color: rgba(0, 0, 0, 0); --card-overlay-bg-color: rgba(0, 0, 0, 0);
--card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2); --card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2);
--card-progress-triangle-size: 20px; --card-progress-triangle-size: 28px;
/* Slider */ /* Slider */
--slider-text-color: white; --slider-text-color: white;

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.6.1.31" "version": "0.6.1.32"
}, },
"servers": [ "servers": [
{ {
@ -9943,6 +9943,11 @@
"description": "Number of the Total Count (progress the Series is complete)", "description": "Number of the Total Count (progress the Series is complete)",
"format": "int32" "format": "int32"
}, },
"seriesGroup": {
"type": "string",
"description": "SeriesGroup tag in ComicInfo",
"nullable": true
},
"wordCount": { "wordCount": {
"type": "integer", "type": "integer",
"description": "Total Word count of all chapters in this chapter.", "description": "Total Word count of all chapters in this chapter.",
@ -11159,6 +11164,10 @@
"type": "boolean", "type": "boolean",
"description": "Include library series in Search" "description": "Include library series in Search"
}, },
"manageCollections": {
"type": "boolean",
"description": "Should this library create and manage collections from Metadata"
},
"created": { "created": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
@ -11232,6 +11241,10 @@
"type": "boolean", "type": "boolean",
"description": "Include Library series on Recommended Streams" "description": "Include Library series on Recommended Streams"
}, },
"manageCollections": {
"type": "boolean",
"description": "Should this library create and manage collections from Metadata"
},
"includeInSearch": { "includeInSearch": {
"type": "boolean", "type": "boolean",
"description": "Include library series in Search" "description": "Include library series in Search"
@ -13711,6 +13724,17 @@
"additionalProperties": false "additionalProperties": false
}, },
"UpdateLibraryDto": { "UpdateLibraryDto": {
"required": [
"createCollections",
"folders",
"folderWatching",
"id",
"includeInDashboard",
"includeInRecommended",
"includeInSearch",
"name",
"type"
],
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
@ -13718,8 +13742,8 @@
"format": "int32" "format": "int32"
}, },
"name": { "name": {
"type": "string", "minLength": 1,
"nullable": true "type": "string"
}, },
"type": { "type": {
"$ref": "#/components/schemas/LibraryType" "$ref": "#/components/schemas/LibraryType"
@ -13728,8 +13752,7 @@
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, }
"nullable": true
}, },
"folderWatching": { "folderWatching": {
"type": "boolean" "type": "boolean"
@ -13742,6 +13765,9 @@
}, },
"includeInSearch": { "includeInSearch": {
"type": "boolean" "type": "boolean"
},
"createCollections": {
"type": "boolean"
} }
}, },
"additionalProperties": false "additionalProperties": false