mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
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:
parent
91a2a6854f
commit
1da27f085c
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
113
API.Tests/Services/CollectionTagServiceTests.cs
Normal file
113
API.Tests/Services/CollectionTagServiceTests.cs
Normal 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}));
|
||||||
|
}
|
||||||
|
}
|
@ -51,7 +51,6 @@ public class DeviceServiceDbTests : AbstractDbTest
|
|||||||
}, user);
|
}, user);
|
||||||
|
|
||||||
Assert.NotNull(device);
|
Assert.NotNull(device);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
@ -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)
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
1754
API/Data/Migrations/20230130210252_AutoCollections.Designer.cs
generated
Normal file
1754
API/Data/Migrations/20230130210252_AutoCollections.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
API/Data/Migrations/20230130210252_AutoCollections.cs
Normal file
36
API/Data/Migrations/20230130210252_AutoCollections.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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");
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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>
|
||||||
|
@ -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>();
|
||||||
|
157
API/Services/CollectionTagService.cs
Normal file
157
API/Services/CollectionTagService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -15,4 +15,5 @@ export interface Library {
|
|||||||
includeInDashboard: boolean;
|
includeInDashboard: boolean;
|
||||||
includeInRecommended: boolean;
|
includeInRecommended: boolean;
|
||||||
includeInSearch: boolean;
|
includeInSearch: boolean;
|
||||||
|
manageCollections: boolean;
|
||||||
}
|
}
|
@ -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))
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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">
|
||||||
|
@ -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();
|
||||||
|
@ -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;
|
||||||
|
36
openapi.json
36
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.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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user