ComicVine Finish Line (#2779)

This commit is contained in:
Joe Milazzo 2024-03-14 15:03:53 -06:00 committed by GitHub
parent 4ccac5f479
commit 353d44a882
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 8108 additions and 1116 deletions

View file

@ -6,17 +6,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.15" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="20.0.15" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="20.0.28" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View file

@ -77,6 +77,53 @@ public class SeriesExtensionsTests
{
var series = new SeriesBuilder("Test 1")
.WithFormat(MangaFormat.Archive)
.WithVolume(new VolumeBuilder(Parser.LooseLeafVolume)
.WithName(Parser.LooseLeafVolume)
.WithChapter(new ChapterBuilder("-1")
.WithCoverImage("Chapter -1")
.Build())
.WithChapter(new ChapterBuilder("0.5")
.WithCoverImage("Chapter 0.5")
.Build())
.WithChapter(new ChapterBuilder("2")
.WithCoverImage("Chapter 2")
.Build())
.WithChapter(new ChapterBuilder("1")
.WithCoverImage("Chapter 1")
.Build())
.WithChapter(new ChapterBuilder("3")
.WithCoverImage("Chapter 3")
.Build())
.WithChapter(new ChapterBuilder("4AU")
.WithCoverImage("Chapter 4AU")
.Build())
.Build())
.Build();
Assert.Equal("Chapter 1", series.GetCoverImage());
}
/// <summary>
/// Checks the case where there are specials and loose leafs, loose leaf chapters should be preferred
/// </summary>
[Fact]
public void GetCoverImage_LooseChapters_WithSub1_Chapter_WithSpecials()
{
var series = new SeriesBuilder("Test 1")
.WithFormat(MangaFormat.Archive)
.WithVolume(new VolumeBuilder(Parser.SpecialVolume)
.WithName(Parser.SpecialVolume)
.WithChapter(new ChapterBuilder("I am a Special")
.WithCoverImage("I am a Special")
.Build())
.WithChapter(new ChapterBuilder("I am a Special 2")
.WithCoverImage("I am a Special 2")
.Build())
.Build())
.WithVolume(new VolumeBuilder(Parser.LooseLeafVolume)
.WithName(Parser.LooseLeafVolume)
.WithChapter(new ChapterBuilder("0.5")

View file

@ -208,8 +208,9 @@ public class ComicParsingTests
[InlineData("Batman Beyond Omnibus (1999)", true)]
[InlineData("Batman Beyond Omnibus", true)]
[InlineData("01 Annual Batman Beyond", true)]
[InlineData("Blood Syndicate Annual #001", true)]
public void IsComicSpecialTest(string input, bool expected)
{
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.IsComicSpecial(input));
Assert.Equal(expected, Parser.IsComicSpecial(input));
}
}

View file

@ -721,6 +721,45 @@ public class DirectoryServiceTests
#endregion
#region FindLowestDirectoriesFromFiles
[Theory]
[InlineData(new [] {"C:/Manga/"},
new [] {"C:/Manga/Love Hina/Vol. 01.cbz"},
"C:/Manga/Love Hina")]
[InlineData(new [] {"C:/Manga/"},
new [] {"C:/Manga/Romance/Love Hina/Vol. 01.cbz"},
"C:/Manga/Romance/Love Hina")]
[InlineData(new [] {"C:/Manga/Dir 1/", "c://Manga/Dir 2/"},
new [] {"C:/Manga/Dir 1/Love Hina/Vol. 01.cbz"},
"C:/Manga/Dir 1/Love Hina")]
[InlineData(new [] {"C:/Manga/Dir 1/", "c://Manga/"},
new [] {"D:/Manga/Love Hina/Vol. 01.cbz", "D:/Manga/Vol. 01.cbz"},
null)]
[InlineData(new [] {"C:/Manga/"},
new [] {"C:/Manga//Love Hina/Vol. 01.cbz"},
"C:/Manga/Love Hina")]
[InlineData(new [] {@"C:\mount\drive\Library\Test Library\Comics\"},
new [] {@"C:\mount\drive\Library\Test Library\Comics\Bruce Lee (1994)\Bruce Lee #001 (1994).cbz"},
@"C:/mount/drive/Library/Test Library/Comics/Bruce Lee (1994)")]
public void FindLowestDirectoriesFromFilesTest(string[] rootDirectories, string[] files, string expectedDirectory)
{
var fileSystem = new MockFileSystem();
foreach (var directory in rootDirectories)
{
fileSystem.AddDirectory(directory);
}
foreach (var f in files)
{
fileSystem.AddFile(f, new MockFileData(""));
}
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var actual = ds.FindLowestDirectoriesFromFiles(rootDirectories, files);
Assert.Equal(expectedDirectory, actual);
}
#endregion
#region GetFoldersTillRoot
[Theory]

View file

@ -53,30 +53,30 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="30.1.0" />
<PackageReference Include="MailKit" Version="4.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.1">
<PackageReference Include="CsvHelper" Version="31.0.2" />
<PackageReference Include="MailKit" Version="4.4.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="Docnet.Core" Version="2.6.0" />
<PackageReference Include="EasyCaching.InMemory" Version="1.9.2" />
<PackageReference Include="ExCSS" Version="4.2.4" />
<PackageReference Include="ExCSS" Version="4.2.5" />
<PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.8.9" />
<PackageReference Include="Hangfire.InMemory" Version="0.7.0" />
<PackageReference Include="Hangfire" Version="1.8.11" />
<PackageReference Include="Hangfire.InMemory" Version="0.8.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.58" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.59" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.9" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
@ -94,16 +94,16 @@
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.36.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.2" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.19.0.84025">
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.3" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.21.0.86780">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.3.1" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.15" />
<PackageReference Include="System.Drawing.Common" Version="8.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.4.0" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
<PackageReference Include="System.Drawing.Common" Version="8.0.3" />
<PackageReference Include="VersOne.Epub" Version="3.3.1" />
</ItemGroup>

View file

@ -33,13 +33,14 @@ public class CblController : BaseApiController
/// <param name="file">FormBody with parameter name of cbl</param>
/// <returns></returns>
[HttpPost("validate")]
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl([FromForm(Name = "cbl")] IFormFile file)
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl([FromForm(Name = "cbl")] IFormFile file,
[FromForm(Name = "comicVineMatching")] bool comicVineMatching = false)
{
var userId = User.GetUserId();
try
{
var cbl = await SaveAndLoadCblFile(file);
var importSummary = await _readingListService.ValidateCblFile(userId, cbl);
var importSummary = await _readingListService.ValidateCblFile(userId, cbl, comicVineMatching);
importSummary.FileName = file.FileName;
return Ok(importSummary);
}
@ -83,13 +84,14 @@ public class CblController : BaseApiController
/// <param name="dryRun">If true, will only emulate the import but not perform. This should be done to preview what will happen</param>
/// <returns></returns>
[HttpPost("import")]
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl([FromForm(Name = "cbl")] IFormFile file, [FromForm(Name = "dryRun")] bool dryRun = false)
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl([FromForm(Name = "cbl")] IFormFile file,
[FromForm(Name = "dryRun")] bool dryRun = false, [FromForm(Name = "comicVineMatching")] bool comicVineMatching = false)
{
try
{
var userId = User.GetUserId();
var cbl = await SaveAndLoadCblFile(file);
var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun);
var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun, comicVineMatching);
importSummary.FileName = file.FileName;
return Ok(importSummary);
} catch (ArgumentNullException)

View file

@ -49,6 +49,8 @@ public enum FilterField
/// Average rating from Kavita+ - Not usable for non-licensed users
/// </summary>
AverageRating = 28,
Imprint = 29
Imprint = 29,
Team = 30,
Location = 31
}

View file

@ -23,6 +23,8 @@ public class ChapterMetadataDto
public ICollection<PersonDto> Letterers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Editors { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Translators { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Teams { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Locations { get; set; } = new List<PersonDto>();
public ICollection<GenreTagDto> Genres { get; set; } = new List<GenreTagDto>();

View file

@ -1,4 +1,5 @@
using System.Xml.Serialization;
using API.Data.Metadata;
namespace API.DTOs.ReadingLists.CBL;
@ -21,6 +22,12 @@ public class CblBook
[XmlAttribute("Year")]
public string Year { get; set; }
/// <summary>
/// Main Series, Annual, Limited Series
/// </summary>
/// <remarks>This maps to <see cref="ComicInfo">Format</see> tag</remarks>
[XmlAttribute("Format")]
public string Format { get; set; }
/// <summary>
/// The underlying filetype
/// </summary>
/// <remarks>This is not part of the standard and explicitly for Kavita to support non cbz/cbr files</remarks>

View file

@ -35,6 +35,9 @@ public class SeriesMetadataDto
public ICollection<PersonDto> Letterers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Editors { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Translators { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Teams { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Locations { get; set; } = new List<PersonDto>();
/// <summary>
/// Highest Age Rating from all Chapters
/// </summary>
@ -86,6 +89,8 @@ public class SeriesMetadataDto
public bool PencillerLocked { get; set; }
public bool PublisherLocked { get; set; }
public bool TranslatorLocked { get; set; }
public bool TeamLocked { get; set; }
public bool LocationLocked { get; set; }
public bool CoverArtistLocked { get; set; }
public bool ReleaseYearLocked { get; set; }

View file

@ -129,6 +129,8 @@ public class ComicInfo
public string Publisher { get; set; } = string.Empty;
public string Imprint { get; set; } = string.Empty;
public string Characters { get; set; } = string.Empty;
public string Teams { get; set; } = string.Empty;
public string Locations { get; set; } = string.Empty;
public static AgeRating ConvertAgeRatingToEnum(string value)
@ -157,6 +159,8 @@ public class ComicInfo
info.Characters = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Characters);
info.Translator = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Translator);
info.CoverArtist = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.CoverArtist);
info.Teams = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Teams);
info.Locations = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Locations);
// We need to convert GTIN to ISBN
if (!string.IsNullOrEmpty(info.GTIN))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class SeriesLowestFolderPath : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LowestFolderPath",
table: "Series",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LowestFolderPath",
table: "Series");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class TeamsAndLocations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "LocationLocked",
table: "SeriesMetadata",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "TeamLocked",
table: "SeriesMetadata",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LocationLocked",
table: "SeriesMetadata");
migrationBuilder.DropColumn(
name: "TeamLocked",
table: "SeriesMetadata");
}
}
}

View file

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.1");
modelBuilder.HasAnnotation("ProductVersion", "8.0.3");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -1268,6 +1268,9 @@ namespace API.Data.Migrations
b.Property<bool>("LettererLocked")
.HasColumnType("INTEGER");
b.Property<bool>("LocationLocked")
.HasColumnType("INTEGER");
b.Property<int>("MaxCount")
.HasColumnType("INTEGER");
@ -1305,6 +1308,9 @@ namespace API.Data.Migrations
b.Property<bool>("TagsLocked")
.HasColumnType("INTEGER");
b.Property<bool>("TeamLocked")
.HasColumnType("INTEGER");
b.Property<int>("TotalCount")
.HasColumnType("INTEGER");
@ -1680,6 +1686,9 @@ namespace API.Data.Migrations
b.Property<bool>("LocalizedNameLocked")
.HasColumnType("INTEGER");
b.Property<string>("LowestFolderPath")
.HasColumnType("TEXT");
b.Property<int>("MaxHoursToRead")
.HasColumnType("INTEGER");

View file

@ -318,7 +318,7 @@ public class LibraryRepository : ILibraryRepository
/// <returns></returns>
public async Task<bool> DoAnySeriesFoldersMatch(IEnumerable<string> folders)
{
var normalized = folders.Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath);
var normalized = folders.Select(Parser.NormalizePath);
return await _context.Series.AnyAsync(s => normalized.Contains(s.FolderPath));
}

View file

@ -1185,6 +1185,8 @@ public class SeriesRepository : ISeriesRepository
FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Imprint => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Team => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Location => 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.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
@ -2053,7 +2055,7 @@ public class SeriesRepository : ISeriesRepository
foreach (var series in info)
{
if (series.FolderPath == null) continue;
if (!map.ContainsKey(series.FolderPath))
if (!map.TryGetValue(series.FolderPath, out var value))
{
map.Add(series.FolderPath, new List<SeriesModified>()
{
@ -2062,9 +2064,8 @@ public class SeriesRepository : ISeriesRepository
}
else
{
map[series.FolderPath].Add(series);
value.Add(series);
}
}
return map;

View file

@ -28,7 +28,7 @@ public enum PersonRole
/// <summary>
/// The publisher before another Publisher bought
/// </summary>
Imprint = 13
Imprint = 13,
Team = 14,
Location = 15
}

View file

@ -73,10 +73,11 @@ public class SeriesMetadata : IHasConcurrencyToken
public bool PencillerLocked { get; set; }
public bool PublisherLocked { get; set; }
public bool TranslatorLocked { get; set; }
public bool TeamLocked { get; set; }
public bool LocationLocked { get; set; }
public bool CoverArtistLocked { get; set; }
public bool ReleaseYearLocked { get; set; }
// Relationship
public Series Series { get; set; } = null!;
public int SeriesId { get; set; }

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Entities.Metadata;
using API.Services.Tasks.Scanner.Parser;
namespace API.Entities;
@ -65,6 +64,11 @@ public class Series : IEntityDate, IHasReadTimeEstimate
/// <remarks><see cref="Services.Tasks.Scanner.Parser.Parser.NormalizePath"/> must be used before setting</remarks>
public string? FolderPath { get; set; }
/// <summary>
/// Lowest path (that is under library root) that contains all files for the series.
/// </summary>
/// <remarks><see cref="Services.Tasks.Scanner.Parser.Parser.NormalizePath"/> must be used before setting</remarks>
public string? LowestFolderPath { get; set; }
/// <summary>
/// Last time the folder was scanned
/// </summary>
public DateTime LastFolderScanned { get; set; }

View file

@ -22,6 +22,12 @@ public static class SeriesExtensions
var firstVolume = volumes.GetCoverImage(series.Format);
if (firstVolume == null) return null;
// If first volume here is specials, move to the next as specials should almost always be last.
if (firstVolume.MinNumber.Is(Parser.SpecialVolumeNumber) && volumes.Count > 1)
{
firstVolume = volumes[1];
}
var chapters = firstVolume.Chapters
.OrderBy(c => c.SortOrder)
.ToList();

View file

@ -70,6 +70,16 @@ public static class VolumeListExtensions
return volumes.FirstOrDefault(v => v.MinNumber.Is(Parser.DefaultChapterNumber));
}
/// <summary>
/// Returns the first (and only) special volume or null if none
/// </summary>
/// <param name="volumes"></param>
/// <returns></returns>
public static Volume? GetSpecialVolumeOrDefault(this IEnumerable<Volume> volumes)
{
return volumes.FirstOrDefault(v => v.MinNumber.Is(Parser.SpecialVolumeNumber));
}
public static IEnumerable<VolumeDto> WhereNotLooseLeaf(this IEnumerable<VolumeDto> volumes)
{
return volumes.Where(v => v.MinNumber.Is(Parser.DefaultChapterNumber));

View file

@ -128,6 +128,14 @@ public class AutoMapperProfiles : Profile
opt =>
opt.MapFrom(
src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Teams,
opt =>
opt.MapFrom(
src => src.People.Where(p => p.Role == PersonRole.Team).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Locations,
opt =>
opt.MapFrom(
src => src.People.Where(p => p.Role == PersonRole.Location).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Genres,
opt =>
opt.MapFrom(
@ -174,7 +182,14 @@ public class AutoMapperProfiles : Profile
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Editors,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName)));
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Teams,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Team).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Locations,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Location).OrderBy(p => p.NormalizedName)))
;
CreateMap<AppUser, UserDto>()
.ForMember(dest => dest.AgeRestriction,

View file

@ -61,6 +61,12 @@ public static class FilterFieldValueConverter
FilterField.Imprint => value.Split(',')
.Select(int.Parse)
.ToList(),
FilterField.Team => value.Split(',')
.Select(int.Parse)
.ToList(),
FilterField.Location => value.Split(',')
.Select(int.Parse)
.ToList(),
FilterField.Penciller => value.Split(',')
.Select(int.Parse)
.ToList(),

View file

@ -9,6 +9,7 @@ using System.Threading.Tasks;
using API.DTOs.System;
using API.Entities.Enums;
using API.Extensions;
using API.Services.Tasks.Scanner.Parser;
using Kavita.Common.Helpers;
using Microsoft.Extensions.Logging;
@ -53,6 +54,8 @@ public interface IDirectoryService
bool CopyDirectoryToDirectory(string? sourceDirName, string destDirName, string searchPattern = "");
Dictionary<string, string> FindHighestDirectoriesFromFiles(IEnumerable<string> libraryFolders,
IList<string> filePaths);
string? FindLowestDirectoriesFromFiles(IEnumerable<string> libraryFolders,
IList<string> filePaths);
IEnumerable<string> GetFoldersTillRoot(string rootPath, string fullPath);
IEnumerable<string> GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly);
bool ExistOrCreate(string directoryPath);
@ -584,6 +587,43 @@ public class DirectoryService : IDirectoryService
return dirs;
}
/// <summary>
/// Finds the lowest directory from a set of file paths. Does not return the root path, will always select the lowest non-root path.
/// </summary>
/// <remarks>If the file paths do not contain anything from libraryFolders, this returns an empty dictionary back</remarks>
/// <param name="libraryFolders">List of top level folders which files belong to</param>
/// <param name="filePaths">List of file paths that belong to libraryFolders</param>
/// <returns></returns>
public string? FindLowestDirectoriesFromFiles(IEnumerable<string> libraryFolders, IList<string> filePaths)
{
var stopLookingForDirectories = false;
var dirs = new Dictionary<string, string>();
foreach (var folder in libraryFolders.Select(Tasks.Scanner.Parser.Parser.NormalizePath))
{
if (stopLookingForDirectories) break;
foreach (var file in filePaths.Select(Tasks.Scanner.Parser.Parser.NormalizePath))
{
if (!file.Contains(folder)) continue;
var lowestPath = Path.GetDirectoryName(file)?.Replace(folder, string.Empty);
if (!string.IsNullOrEmpty(lowestPath))
{
dirs.TryAdd(Parser.NormalizePath(lowestPath), string.Empty);
}
}
}
if (dirs.Keys.Count == 1) return dirs.Keys.First();
if (dirs.Keys.Count > 1)
{
return dirs.Keys.Last();
}
return null;
}
/// <summary>
/// Gets a set of directories from the folder path. Automatically excludes directories that shouldn't be in scope.
/// </summary>

View file

@ -131,8 +131,8 @@ public class MetadataService : IMetadataService
null, series.Created, forceUpdate, series.CoverImageLocked))
return Task.CompletedTask;
series.Volumes ??= new List<Volume>();
series.CoverImage = series.GetCoverImage(); // BUG: At this point the volume or chapter hasn't regenerated the cover
series.Volumes ??= [];
series.CoverImage = series.GetCoverImage();
_updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series));
return Task.CompletedTask;

View file

@ -36,8 +36,8 @@ public interface IReadingListService
Task<bool> AddChaptersToReadingList(int seriesId, IList<int> chapterIds,
ReadingList readingList);
Task<CblImportSummaryDto> ValidateCblFile(int userId, CblReadingList cblReading);
Task<CblImportSummaryDto> CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false);
Task<CblImportSummaryDto> ValidateCblFile(int userId, CblReadingList cblReading, bool useComicLibraryMatching = false);
Task<CblImportSummaryDto> CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false, bool useComicLibraryMatching = false);
Task CalculateStartAndEndDates(ReadingList readingListWithItems);
/// <summary>
/// This is expected to be called from ProcessSeries and has the Full Series present. Will generate on the default admin user.
@ -530,7 +530,8 @@ public class ReadingListService : IReadingListService
/// </summary>
/// <param name="userId"></param>
/// <param name="cblReading"></param>
public async Task<CblImportSummaryDto> ValidateCblFile(int userId, CblReadingList cblReading)
/// <param name="useComicLibraryMatching">When true, will force ComicVine library naming conventions: Series (Year) for Series name matching.</param>
public async Task<CblImportSummaryDto> ValidateCblFile(int userId, CblReadingList cblReading, bool useComicLibraryMatching = false)
{
var importSummary = new CblImportSummaryDto
{
@ -552,9 +553,14 @@ public class ReadingListService : IReadingListService
});
}
var uniqueSeries = cblReading.Books.Book.Select(b => Parser.Normalize(b.Series)).Distinct().ToList();
var uniqueSeries = GetUniqueSeries(cblReading, useComicLibraryMatching);
var userSeries =
(await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList();
// How can we match properly with ComicVine library when year is part of the series unless we do this in 2 passes and see which has a better match
if (!userSeries.Any())
{
// Report that no series exist in the reading list
@ -584,6 +590,20 @@ public class ReadingListService : IReadingListService
return importSummary;
}
private static string GetSeriesFormatting(CblBook book, bool useComicLibraryMatching)
{
return useComicLibraryMatching ? $"{book.Series} ({book.Volume})" : book.Series;
}
private static List<string> GetUniqueSeries(CblReadingList cblReading, bool useComicLibraryMatching)
{
if (useComicLibraryMatching)
{
return cblReading.Books.Book.Select(b => Parser.Normalize(GetSeriesFormatting(b, useComicLibraryMatching))).Distinct().ToList();
}
return cblReading.Books.Book.Select(b => Parser.Normalize(GetSeriesFormatting(b, useComicLibraryMatching))).Distinct().ToList();
}
/// <summary>
/// Imports (or pretends to) a cbl into a reading list. Call <see cref="ValidateCblFile"/> first!
@ -591,8 +611,9 @@ public class ReadingListService : IReadingListService
/// <param name="userId"></param>
/// <param name="cblReading"></param>
/// <param name="dryRun"></param>
/// <param name="useComicLibraryMatching">When true, will force ComicVine library naming conventions: Series (Year) for Series name matching.</param>
/// <returns></returns>
public async Task<CblImportSummaryDto> CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false)
public async Task<CblImportSummaryDto> CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false, bool useComicLibraryMatching = false)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.ReadingListsWithItems);
_logger.LogDebug("Importing {ReadingListName} CBL for User {UserName}", cblReading.Name, user!.UserName);
@ -604,11 +625,11 @@ public class ReadingListService : IReadingListService
SuccessfulInserts = new List<CblBookResult>()
};
var uniqueSeries = cblReading.Books.Book.Select(b => Parser.Normalize(b.Series)).Distinct().ToList();
var uniqueSeries = GetUniqueSeries(cblReading, useComicLibraryMatching);
var userSeries =
(await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList();
var allSeries = userSeries.ToDictionary(s => Parser.Normalize(s.Name));
var allSeriesLocalized = userSeries.ToDictionary(s => Parser.Normalize(s.LocalizedName));
var allSeries = userSeries.ToDictionary(s => s.NormalizedName);
var allSeriesLocalized = userSeries.ToDictionary(s => s.NormalizedLocalizedName);
var readingListNameNormalized = Parser.Normalize(cblReading.Name);
// Get all the user's reading lists
@ -635,27 +656,7 @@ public class ReadingListService : IReadingListService
readingList.Items ??= new List<ReadingListItem>();
foreach (var (book, i) in cblReading.Books.Book.Select((value, i) => ( value, i )))
{
// I want to refactor this so that we move the matching logic into a method.
// But when I looked, we are returning statuses on different conditions, hard to keep it single responsibility
// Either refactor to return an enum for the state, make it return the BookResult, or refactor the reasoning so it's more straightforward
// var match = FindMatchingCblBookSeries(book);
// if (match == null)
// {
// importSummary.Results.Add(new CblBookResult(book)
// {
// Reason = CblImportReason.SeriesMissing,
// Order = i
// });
// continue;
// }
// TODO: I need a dedicated db query to get Series name's processed if they are ComicVine.
// In comicvine, series names are Series(Volume), but the spec just has Series="Series" Volume="Volume"
// So we need to combine them for comics that are in ComicVine libraries.
var normalizedSeries = Parser.Normalize(book.Series);
var normalizedSeries = Parser.Normalize(GetSeriesFormatting(book, useComicLibraryMatching));
if (!allSeries.TryGetValue(normalizedSeries, out var bookSeries) && !allSeriesLocalized.TryGetValue(normalizedSeries, out bookSeries))
{
importSummary.Results.Add(new CblBookResult(book)
@ -669,7 +670,9 @@ public class ReadingListService : IReadingListService
var bookVolume = string.IsNullOrEmpty(book.Volume)
? Parser.LooseLeafVolume
: book.Volume;
var matchingVolume = bookSeries.Volumes.Find(v => bookVolume == v.Name) ?? bookSeries.Volumes.GetLooseLeafVolumeOrDefault();
var matchingVolume = bookSeries.Volumes.Find(v => bookVolume == v.Name)
?? bookSeries.Volumes.GetLooseLeafVolumeOrDefault()
?? bookSeries.Volumes.GetSpecialVolumeOrDefault();
if (matchingVolume == null)
{
importSummary.Results.Add(new CblBookResult(book)
@ -683,9 +686,9 @@ public class ReadingListService : IReadingListService
// We need to handle default chapter or empty string when it's just a volume
var bookNumber = string.IsNullOrEmpty(book.Number)
? Parser.DefaultChapterNumber
: float.Parse(book.Number);
var chapter = matchingVolume.Chapters.FirstOrDefault(c => c.MinNumber.Is(bookNumber));
? Parser.DefaultChapter
: book.Number;
var chapter = matchingVolume.Chapters.FirstOrDefault(c => c.Range == bookNumber);
if (chapter == null)
{
importSummary.Results.Add(new CblBookResult(book)
@ -743,7 +746,7 @@ public class ReadingListService : IReadingListService
private static IList<Series> FindCblImportConflicts(IEnumerable<Series> userSeries)
{
var dict = new HashSet<string>();
return userSeries.Where(series => !dict.Add(Parser.Normalize(series.Name))).ToList();
return userSeries.Where(series => !dict.Add(series.NormalizedName)).ToList();
}
private static bool IsCblEmpty(CblReadingList cblReading, CblImportSummaryDto importSummary,

View file

@ -259,9 +259,19 @@ public class SeriesService : ISeriesService
var allImprints = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Imprint,
updateSeriesMetadataDto.SeriesMetadata!.Imprints.Select(p => Parser.Normalize(p.Name)));
PersonHelper.UpdatePeopleList(PersonRole.Imprint, updateSeriesMetadataDto.SeriesMetadata.Publishers, series, allImprints.AsReadOnly(),
PersonHelper.UpdatePeopleList(PersonRole.Imprint, updateSeriesMetadataDto.SeriesMetadata.Imprints, series, allImprints.AsReadOnly(),
HandleAddPerson, () => series.Metadata.ImprintLocked = true);
var allTeams = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Team,
updateSeriesMetadataDto.SeriesMetadata!.Imprints.Select(p => Parser.Normalize(p.Name)));
PersonHelper.UpdatePeopleList(PersonRole.Team, updateSeriesMetadataDto.SeriesMetadata.Teams, series, allTeams.AsReadOnly(),
HandleAddPerson, () => series.Metadata.TeamLocked = true);
var allLocations = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Location,
updateSeriesMetadataDto.SeriesMetadata!.Imprints.Select(p => Parser.Normalize(p.Name)));
PersonHelper.UpdatePeopleList(PersonRole.Location, updateSeriesMetadataDto.SeriesMetadata.Locations, series, allLocations.AsReadOnly(),
HandleAddPerson, () => series.Metadata.LocationLocked = true);
var allTranslators = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Translator,
updateSeriesMetadataDto.SeriesMetadata!.Translators.Select(p => Parser.Normalize(p.Name)));
PersonHelper.UpdatePeopleList(PersonRole.Translator, updateSeriesMetadataDto.SeriesMetadata.Translators, series, allTranslators.AsReadOnly(),

View file

@ -76,7 +76,7 @@ public class ScannedSeriesResult
public class SeriesModified
{
public required string FolderPath { get; set; }
public required string? FolderPath { get; set; }
public required string SeriesName { get; set; }
public DateTime LastScanned { get; set; }
public MangaFormat Format { get; set; }
@ -416,7 +416,6 @@ public class ParseScannedFiles
.ToList()!;
result.ParserInfos = infos;
return;
}
@ -453,7 +452,7 @@ public class ParseScannedFiles
if (float.TryParse(chapter.Chapters, out var parsedChapter))
{
counter = parsedChapter;
if (!string.IsNullOrEmpty(prevIssue) && parsedChapter.Is(float.Parse(prevIssue)))
if (!string.IsNullOrEmpty(prevIssue) && float.TryParse(prevIssue, out var prevIssueFloat) && parsedChapter.Is(prevIssueFloat))
{
// Bump by 0.1
counter += 0.1f;

View file

@ -24,12 +24,16 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser
if (type != LibraryType.ComicVine) return null;
var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
// Mylar often outputs cover.jpg, ignore it by default
if (string.IsNullOrEmpty(fileName) || Parser.IsCoverImage(directoryService.FileSystem.Path.GetFileName(filePath))) return null;
var directoryName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name;
var info = new ParserInfo()
{
Filename = Path.GetFileName(filePath),
Format = Parser.ParseFormat(filePath),
Title = Parser.RemoveExtensionIfSupported(fileName),
Title = Parser.RemoveExtensionIfSupported(fileName)!,
FullFilePath = filePath,
Series = string.Empty,
ComicInfo = comicInfo,
@ -73,7 +77,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser
}
}
// Check if this is a Special
// Check if this is a Special/Annual
info.IsSpecial = Parser.IsComicSpecial(info.Filename) || Parser.IsComicSpecial(info.ComicInfo?.Format);
// Patch in other information from ComicInfo

View file

@ -641,7 +641,7 @@ public static class Parser
private static readonly Regex ComicSpecialRegex = new Regex(
// All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle.
$@"\b(?:{CommonSpecial}|\d.+?(\W|-|^)Annual|Annual(\W|-|$)|Book \d.+?|Compendium(\W|-|$|\s.+?)|Omnibus(\W|-|$|\s.+?)|FCBD \d.+?|Absolute(\W|-|$|\s.+?)|Preview(\W|-|$|\s.+?)|Hors[ -]S[ée]rie|TPB|HS|THS)\b",
$@"\b(?:{CommonSpecial}|\d.+?(\W|-|^)Annual|Annual(\W|-|$|\s#)|Book \d.+?|Compendium(\W|-|$|\s.+?)|Omnibus(\W|-|$|\s.+?)|FCBD \d.+?|Absolute(\W|-|$|\s.+?)|Preview(\W|-|$|\s.+?)|Hors[ -]S[ée]rie|TPB|HS|THS)\b",
MatchOptions, RegexTimeout
);

View file

@ -258,8 +258,9 @@ public class ProcessSeries : IProcessSeries
private async Task UpdateSeriesFolderPath(IEnumerable<ParserInfo> parsedInfos, Library library, Series series)
{
var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(library.Folders.Select(l => l.Path),
parsedInfos.Select(f => f.FullFilePath).ToList());
var libraryFolders = library.Folders.Select(l => Parser.Parser.NormalizePath(l.Path)).ToList();
var seriesFiles = parsedInfos.Select(f => Parser.Parser.NormalizePath(f.FullFilePath)).ToList();
var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(libraryFolders, seriesFiles);
if (seriesDirs.Keys.Count == 0)
{
_logger.LogCritical(
@ -273,10 +274,19 @@ public class ProcessSeries : IProcessSeries
// Don't save FolderPath if it's a library Folder
if (!library.Folders.Select(f => f.Path).Contains(seriesDirs.Keys.First()))
{
// BUG: FolderPath can be a level higher than it needs to be. I'm not sure why it's like this, but I thought it should be one level lower.
// I think it's like this because higher level is checked or not checked. But i think we can do both
series.FolderPath = Parser.Parser.NormalizePath(seriesDirs.Keys.First());
_logger.LogDebug("Updating {Series} FolderPath to {FolderPath}", series.Name, series.FolderPath);
}
}
var lowestFolder = _directoryService.FindLowestDirectoriesFromFiles(libraryFolders, seriesFiles);
if (!string.IsNullOrEmpty(lowestFolder))
{
series.LowestFolderPath = lowestFolder;
_logger.LogDebug("Updating {Series} LowestFolderPath to {FolderPath}", series.Name, series.LowestFolderPath);
}
}
@ -439,6 +449,22 @@ public class ProcessSeries : IProcessSeries
}
}
if (!series.Metadata.TeamLocked)
{
foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Team))
{
PersonHelper.AddPersonIfNotExists(series.Metadata.People, person);
}
}
if (!series.Metadata.LocationLocked)
{
foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Location))
{
PersonHelper.AddPersonIfNotExists(series.Metadata.People, person);
}
}
if (!series.Metadata.LettererLocked)
{
foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Letterer))
@ -837,6 +863,14 @@ public class ProcessSeries : IProcessSeries
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Imprint);
await UpdatePeople(chapter, people, PersonRole.Imprint);
people = TagHelper.GetTagValues(comicInfo.Teams);
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Team);
await UpdatePeople(chapter, people, PersonRole.Team);
people = TagHelper.GetTagValues(comicInfo.Locations);
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Location);
await UpdatePeople(chapter, people, PersonRole.Location);
var genres = TagHelper.GetTagValues(comicInfo.Genre);
GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres,
genres.Select(g => new GenreBuilder(g).Build()).ToList());

View file

@ -210,7 +210,7 @@ public class ScannerService : IScannerService
return;
}
var folderPath = series.FolderPath;
var folderPath = series.LowestFolderPath ?? series.FolderPath;
if (string.IsNullOrEmpty(folderPath) || !_directoryService.Exists(folderPath))
{
// We don't care if it's multiple due to new scan loop enforcing all in one root directory
@ -265,7 +265,8 @@ public class ScannerService : IScannerService
if (parsedSeries.Count == 0)
{
var seriesFiles = (await _unitOfWork.SeriesRepository.GetFilesForSeries(series.Id));
if (!string.IsNullOrEmpty(series.FolderPath) && !seriesFiles.Where(f => f.FilePath.Contains(series.FolderPath)).Any(m => File.Exists(m.FilePath)))
if (!string.IsNullOrEmpty(series.FolderPath) &&
!seriesFiles.Where(f => f.FilePath.Contains(series.FolderPath)).Any(m => File.Exists(m.FilePath)))
{
try
{

View file

@ -10,15 +10,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cronos" Version="0.8.2" />
<PackageReference Include="Cronos" Version="0.8.4" />
<PackageReference Include="DotNet.Glob" Version="3.1.3" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.19.0.84025">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.21.0.86780">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.assert" Version="2.6.6" />
<PackageReference Include="xunit.assert" Version="2.7.0" />
</ItemGroup>
</Project>

View file

@ -33,3 +33,7 @@ and update environment.ts to your local ip.
## Notes:
- injected services should be at the top of the file
- all components must be standalone
# Update latest angular
`ng update @angular/core @angular/cli @typescript-es
lint/parser @angular/localize @angular/compiler-cli @angular/cli @angular-devkit/build-angular @angular/cdk`

2452
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,16 +15,16 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^17.1.0",
"@angular/cdk": "^17.1.0",
"@angular/common": "^17.1.0",
"@angular/compiler": "^17.1.0",
"@angular/core": "^17.1.0",
"@angular/forms": "^17.1.0",
"@angular/localize": "^17.1.0",
"@angular/platform-browser": "^17.1.0",
"@angular/platform-browser-dynamic": "^17.1.0",
"@angular/router": "^17.1.0",
"@angular/animations": "^17.3.0",
"@angular/cdk": "^17.2.2",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/localize": "^17.3.0",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"@fortawesome/fontawesome-free": "^6.5.1",
"@iharbeck/ngx-virtual-scroller": "^17.0.0",
"@iplab/ngx-file-upload": "^17.0.0",
@ -37,7 +37,7 @@
"@ngneat/transloco-preload-langs": "^5.0.1",
"@popperjs/core": "^2.11.7",
"@swimlane/ngx-charts": "^20.5.0",
"@tweenjs/tween.js": "^21.0.0",
"@tweenjs/tween.js": "^21.1.1",
"bootstrap": "^5.3.2",
"charts.css": "^1.1.0",
"file-saver": "^2.0.5",
@ -57,24 +57,24 @@
"screenfull": "^6.0.2",
"swiper": "^8.4.6",
"tslib": "^2.6.2",
"zone.js": "^0.14.2"
"zone.js": "^0.14.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.1.0",
"@angular-devkit/build-angular": "^17.3.0",
"@angular-eslint/builder": "^17.2.1",
"@angular-eslint/eslint-plugin": "^17.2.1",
"@angular-eslint/eslint-plugin-template": "^17.2.1",
"@angular-eslint/schematics": "^17.2.1",
"@angular-eslint/template-parser": "^17.2.1",
"@angular/cli": "^17.1.0",
"@angular/compiler-cli": "^17.1.0",
"@angular/cli": "^17.3.0",
"@angular/compiler-cli": "^17.3.0",
"@types/d3": "^7.4.3",
"@types/file-saver": "^2.0.7",
"@types/luxon": "^3.4.0",
"@types/node": "^20.10.0",
"@typescript-eslint/eslint-plugin": "^6.13.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint": "^8.54.0",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"eslint": "^8.57.0",
"jsonminify": "^0.4.2",
"karma-coverage": "~2.2.0",
"ts-node": "~10.9.1",

View file

@ -34,6 +34,8 @@ export interface ChapterMetadata {
letterers: Array<Person>;
editors: Array<Person>;
translators: Array<Person>;
teams: Array<Person>;
locations: Array<Person>;

View file

@ -11,7 +11,9 @@ export enum PersonRole {
Publisher = 10,
Character = 11,
Translator = 12,
Imprint = 13
Imprint = 13,
Team = 14,
Location = 15
}
export interface Person {

View file

@ -26,6 +26,8 @@ export interface SeriesMetadata {
letterers: Array<Person>;
editors: Array<Person>;
translators: Array<Person>;
teams: Array<Person>;
locations: Array<Person>;
ageRating: AgeRating;
releaseYear: number;
language: string;
@ -46,6 +48,8 @@ export interface SeriesMetadata {
lettererLocked: boolean;
editorLocked: boolean;
translatorLocked: boolean;
teamLocked: boolean;
locationLocked: boolean;
ageRatingLocked: boolean;
releaseYearLocked: boolean;
languageLocked: boolean;

View file

@ -30,7 +30,9 @@ export enum FilterField
WantToRead = 26,
ReadingDate = 27,
AverageRating = 28,
Imprint = 29
Imprint = 29,
Team = 30,
Location = 31
}

View file

@ -30,6 +30,10 @@ export class FilterFieldPipe implements PipeTransform {
return translate('filter-field-pipe.inker');
case FilterField.Imprint:
return translate('filter-field-pipe.imprint');
case FilterField.Team:
return translate('filter-field-pipe.team');
case FilterField.Location:
return translate('filter-field-pipe.location');
case FilterField.Languages:
return translate('filter-field-pipe.languages');
case FilterField.Libraries:

View file

@ -33,6 +33,12 @@ export class PersonRolePipe implements PipeTransform {
return this.translocoService.translate('person-role-pipe.imprint');
case PersonRole.Writer:
return this.translocoService.translate('person-role-pipe.writer');
case PersonRole.Team:
return this.translocoService.translate('person-role-pipe.team');
case PersonRole.Location:
return this.translocoService.translate('person-role-pipe.location');
case PersonRole.Translator:
return this.translocoService.translate('person-role-pipe.translator');
case PersonRole.Other:
return this.translocoService.translate('person-role-pipe.other');
default:

View file

@ -327,7 +327,21 @@
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="translator" class="form-label">{{t('translator-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);metadata.translatorLocked = true;" [settings]="getPersonsSettings(PersonRole.Translator)"
[(locked)]="metadata.translatorLocked" (onUnlock)="metadata.translatorLocked = false"
(newItemAdded)="metadata.translatorLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
@ -344,12 +358,29 @@
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="translator" class="form-label">{{t('translator-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);metadata.translatorLocked = true;" [settings]="getPersonsSettings(PersonRole.Translator)"
[(locked)]="metadata.translatorLocked" (onUnlock)="metadata.translatorLocked = false"
(newItemAdded)="metadata.translatorLocked = true">
<label for="team" class="form-label">{{t('team-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);metadata.teamLocked = true" [settings]="getPersonsSettings(PersonRole.Team)"
[(locked)]="metadata.teamLocked" (onUnlock)="metadata.teamLocked = false"
(newItemAdded)="metadata.teamLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="location" class="form-label">{{t('location-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location);metadata.locationLocked = true" [settings]="getPersonsSettings(PersonRole.Location)"
[(locked)]="metadata.locationLocked" (onUnlock)="metadata.locationLocked = false"
(newItemAdded)="metadata.locationLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>

View file

@ -488,7 +488,9 @@ export class EditSeriesModalComponent implements OnInit {
this.updateFromPreset('penciller', this.metadata.pencillers, PersonRole.Penciller),
this.updateFromPreset('publisher', this.metadata.publishers, PersonRole.Publisher),
this.updateFromPreset('imprint', this.metadata.imprints, PersonRole.Imprint),
this.updateFromPreset('translator', this.metadata.translators, PersonRole.Translator)
this.updateFromPreset('translator', this.metadata.translators, PersonRole.Translator),
this.updateFromPreset('teams', this.metadata.teams, PersonRole.Team),
this.updateFromPreset('locations', this.metadata.locations, PersonRole.Location),
]).pipe(map(results => {
return of(true);
}));
@ -611,6 +613,10 @@ export class EditSeriesModalComponent implements OnInit {
updatePerson(persons: Person[], role: PersonRole) {
switch (role) {
case PersonRole.Other:
break;
case PersonRole.Artist:
break;
case PersonRole.CoverArtist:
this.metadata.coverArtists = persons;
break;
@ -638,11 +644,19 @@ export class EditSeriesModalComponent implements OnInit {
case PersonRole.Imprint:
this.metadata.imprints = persons;
break;
case PersonRole.Team:
this.metadata.teams = persons;
break;
case PersonRole.Location:
this.metadata.locations = persons;
break;
case PersonRole.Writer:
this.metadata.writers = persons;
break;
case PersonRole.Translator:
this.metadata.translators = persons;
break;
}
this.cdRef.markForCheck();
}

View file

@ -5,7 +5,8 @@
&& chapter.colorists.length === 0 && chapter.letterers.length === 0
&& chapter.editors.length === 0 && chapter.publishers.length === 0
&& chapter.characters.length === 0 && chapter.translators.length === 0
&& chapter.imprints.length === 0">
&& chapter.imprints.length === 0 && chapter.locations.length === 0
&& chapter.teams.length === 0">
{{t('no-data')}}
</span>
<div class="container-flex row row-cols-auto row-cols-lg-5 g-2 g-lg-3 me-0 mt-2">
@ -99,6 +100,25 @@
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.teams && chapter.teams.length > 0">
<h6>{{t('teams-title')}}</h6>
<app-badge-expander [items]="chapter.teams">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.locations && chapter.locations.length > 0">
<h6>{{t('locations-title')}}</h6>
<app-badge-expander [items]="chapter.locations">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.translators && chapter.translators.length > 0">
<h6>{{t('translators-title')}}</h6>
<app-badge-expander [items]="chapter.translators">

View file

@ -65,7 +65,7 @@ const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, Fi
FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags,
FilterField.Imprint
FilterField.Imprint, FilterField.Team, FilterField.Location
];
const BooleanFields = [FilterField.WantToRead];
const DateFields = [FilterField.ReadingDate];
@ -299,6 +299,8 @@ export class MetadataFilterRowComponent implements OnInit {
case FilterField.Penciller: return this.getPersonOptions(PersonRole.Penciller);
case FilterField.Publisher: return this.getPersonOptions(PersonRole.Publisher);
case FilterField.Imprint: return this.getPersonOptions(PersonRole.Imprint);
case FilterField.Team: return this.getPersonOptions(PersonRole.Imprint);
case FilterField.Location: return this.getPersonOptions(PersonRole.Imprint);
case FilterField.Translators: return this.getPersonOptions(PersonRole.Translator);
case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer);
}

View file

@ -148,6 +148,8 @@ export class NavHeaderComponent implements OnInit {
this.clearSearch();
filter = filter + '';
switch(role) {
case PersonRole.Other:
break;
case PersonRole.Writer:
this.goTo({field: FilterField.Writers, comparison: FilterComparison.Equal, value: filter});
break;
@ -181,9 +183,16 @@ export class NavHeaderComponent implements OnInit {
case PersonRole.Imprint:
this.goTo({field: FilterField.Imprint, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Team:
this.goTo({field: FilterField.Team, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Location:
this.goTo({field: FilterField.Location, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Translator:
this.goTo({field: FilterField.Translators, comparison: FilterComparison.Equal, value: filter});
break;
}
}

View file

@ -21,7 +21,6 @@
<ng-container *ngIf="currentStepIndex === Step.Validate">
<p>{{t('validate-description')}}</p>
<div class="row g-0">
<div ngbAccordion #accordion="ngbAccordion">
@for(fileToProcess of filesToProcess; track fileToProcess.fileName) {
<div ngbAccordionItem *ngIf="fileToProcess.validateSummary as summary">
@ -32,26 +31,7 @@
</h5>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
@if(summary.results.length > 0) {
<h5>{{t('validate-warning')}}</h5>
<ol class="list-group list-group-numbered list-group-flush" >
<li class="list-group-item no-hover" *ngFor="let result of summary.results"
[innerHTML]="result | cblConflictReason | safeHtml">
</li>
</ol>
} @else {
<div class="justify-content-center col">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="fa-solid fa-circle-check" style="font-size: 24px" aria-hidden="true"></i>
</div>
<div class="flex-grow-1 ms-3">
{{t('validate-no-issue')}}
</div>
</div>
{{t('validate-no-issue-description')}}
</div>
}
<ng-container [ngTemplateOutlet]="validationList" [ngTemplateOutletContext]="{ summary: summary }"></ng-container>
</div>
</div>
</div>
@ -105,6 +85,38 @@
</ng-container>
</div>
<ng-template #validationList let-summary="summary">
@if (summary.results.length > 0) {
<div class="justify-content-center col">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="fa-solid fa-triangle-exclamation" style="font-size: 24px" aria-hidden="true"></i>
</div>
<div class="flex-grow-1 ms-3">
{{t('validate-warning')}}
</div>
</div>
</div>
<ol class="list-group list-group-numbered list-group-flush" >
<li class="list-group-item no-hover" *ngFor="let result of summary.results"
[innerHTML]="result | cblConflictReason | safeHtml">
</li>
</ol>
}
@else {
<div class="justify-content-center col">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="fa-solid fa-circle-check" style="font-size: 24px" aria-hidden="true"></i>
</div>
<div class="flex-grow-1 ms-3">
{{t('validate-no-issue-description')}}
</div>
</div>
</div>
}
</ng-template>
<ng-template #resultsList let-summary="summary">
<ul class="list-group list-group-flush">
@for(result of summary.results; track result.order) {
@ -115,24 +127,47 @@
</ng-template>
<ng-template #heading let-filename="filename" let-summary="summary">
<ng-container *ngIf="summary.success | cblImportResult as success">
<ng-container [ngSwitch]="summary.success">
<span *ngSwitchCase="CblImportResult.Success" class="badge bg-primary me-1">{{success}}</span>
<span *ngSwitchCase="CblImportResult.Fail" class="badge bg-danger me-1">{{success}}</span>
<span *ngSwitchCase="CblImportResult.Partial" class="badge bg-warning me-1">{{success}}</span>
</ng-container>
</ng-container>
@switch (summary.success) {
@case (CblImportResult.Success) {
<span class="badge heading-badge bg-primary me-1">{{summary.success | cblImportResult}}</span>
}
@case (CblImportResult.Fail) {
<span class="badge heading-badge bg-danger me-1">{{summary.success | cblImportResult}}</span>
}
@case (CblImportResult.Partial) {
<span class="badge heading-badge bg-warning me-1">{{summary.success | cblImportResult}}</span>
}
}
<span>{{filename}}<span *ngIf="summary.cblName">: ({{summary.cblName}})</span></span>
</ng-template>
</div>
<div class="modal-footer">
<form [formGroup]="cblSettingsForm" class="row align-items-center">
<div class="col-auto">
<div class="form-check form-switch">
<input type="checkbox" id="settings-comicvine-mode" role="switch" formControlName="comicVineMatching" class="form-check-input"
aria-labelledby="auto-close-label">
<label class="form-check-label" for="settings-comicvine-mode">{{t('comicvine-parsing-label')}}</label>
</div>
</div>
</form>
<!-- Spacer -->
<div class="col" aria-hidden="true"></div>
<div class="col-auto">
<a class="btn btn-icon" href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/reading-lists#creating-a-reading-list-via-cbl" target="_blank" rel="noopener noreferrer">Help</a>
</div>
<div class="col-auto">
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" (click)="prevStep()" [disabled]="!canMoveToPrevStep()">{{t('prev')}}</button>
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(NextButtonLabel)}}</button>
</div>
</div>

View file

@ -2,6 +2,10 @@
display: none;
}
.heading-badge {
color: var(--bs-badge-color);
}
::ng-deep .file-info {
width: 83%;
float: left;

View file

@ -46,8 +46,12 @@ enum Step {
})
export class ImportCblModalComponent {
protected readonly CblImportResult = CblImportResult;
protected readonly Step = Step;
@ViewChild('fileUpload') fileUpload!: ElementRef<HTMLInputElement>;
fileUploadControl = new FormControl<undefined | Array<File>>(undefined, [
FileUploadValidators.accept(['.cbl']),
]);
@ -55,6 +59,9 @@ export class ImportCblModalComponent {
uploadForm = new FormGroup({
files: this.fileUploadControl
});
cblSettingsForm = new FormGroup({
comicVineMatching: new FormControl(true, [])
});
isLoading: boolean = false;
@ -70,10 +77,6 @@ export class ImportCblModalComponent {
failedFiles: Array<FileStep> = [];
get Breakpoint() { return Breakpoint; }
get Step() { return Step; }
get CblImportResult() { return CblImportResult; }
get NextButtonLabel() {
switch(this.currentStepIndex) {
case Step.DryRun:
@ -105,11 +108,12 @@ export class ImportCblModalComponent {
return;
}
// Load each file into filesToProcess and group their data
let pages = [];
const pages = [];
for (let i = 0; i < files.length; i++) {
const formData = new FormData();
formData.append('cbl', files[i]);
formData.append('dryRun', true + '');
formData.append('dryRun', 'true');
formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + '');
pages.push(this.readingListService.validateCbl(formData));
}
forkJoin(pages).subscribe(results => {
@ -195,11 +199,12 @@ export class ImportCblModalComponent {
const filenamesAllowedToProcess = this.filesToProcess.map(p => p.fileName);
const files = (this.uploadForm.get('files')?.value || []).filter(f => filenamesAllowedToProcess.includes(f.name));
let pages = [];
const pages = [];
for (let i = 0; i < files.length; i++) {
const formData = new FormData();
formData.append('cbl', files[i]);
formData.append('dryRun', 'true');
formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + '');
pages.push(this.readingListService.importCbl(formData));
}
forkJoin(pages).subscribe(results => {
@ -224,6 +229,7 @@ export class ImportCblModalComponent {
const formData = new FormData();
formData.append('cbl', files[i]);
formData.append('dryRun', 'false');
formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + '');
pages.push(this.readingListService.importCbl(formData));
}
forkJoin(pages).subscribe(results => {

View file

@ -76,15 +76,6 @@
<div #collapse="ngbCollapse" [(ngbCollapse)]="isCollapsed" id="extended-series-metadata">
<!-- @if (libraryType === LibraryType.Comic || libraryType === LibraryType.Images) {-->
<!-- <app-metadata-detail [tags]="seriesMetadata.writers" [libraryId]="series.libraryId" [queryParam]="FilterField.Writers" [heading]="t('writers-title')">-->
<!-- <ng-template #itemTemplate let-item>-->
<!-- <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>-->
<!-- </ng-template>-->
<!-- </app-metadata-detail>-->
<!-- -->
<!-- }-->
<app-metadata-detail [tags]="seriesMetadata.coverArtists" [libraryId]="series.libraryId" [queryParam]="FilterField.CoverArtist" [heading]="t('cover-artists-title')">
<ng-template #itemTemplate let-item>
@ -92,26 +83,6 @@
</ng-template>
</app-metadata-detail>
<!-- <app-metadata-detail [tags]="seriesMetadata.writers" [libraryId]="series.libraryId" [queryParam]="FilterField.Writers" [heading]="t('writers-title')">-->
<!-- <ng-template #itemTemplate let-item>-->
<!-- <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>-->
<!-- </ng-template>-->
<!-- </app-metadata-detail>-->
<!-- <app-metadata-detail [tags]="seriesMetadata.coverArtists" [libraryId]="series.libraryId" [queryParam]="FilterField.CoverArtist" [heading]="t('cover-artists-title')">-->
<!-- <ng-template #itemTemplate let-item>-->
<!-- <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>-->
<!-- </ng-template>-->
<!-- </app-metadata-detail>-->
<app-metadata-detail [tags]="seriesMetadata.characters" [libraryId]="series.libraryId" [queryParam]="FilterField.Characters" [heading]="t('characters-title')">
<ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template>
</app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.colorists" [libraryId]="series.libraryId" [queryParam]="FilterField.Colorist" [heading]="t('colorists-title')">
<ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
@ -136,13 +107,25 @@
</ng-template>
</app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.translators" [libraryId]="series.libraryId" [queryParam]="FilterField.Translators" [heading]="t('translators-title')">
<app-metadata-detail [tags]="seriesMetadata.pencillers" [libraryId]="series.libraryId" [queryParam]="FilterField.Penciller" [heading]="t('pencillers-title')">
<ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template>
</app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.pencillers" [libraryId]="series.libraryId" [queryParam]="FilterField.Penciller" [heading]="t('pencillers-title')">
<app-metadata-detail [tags]="seriesMetadata.characters" [libraryId]="series.libraryId" [queryParam]="FilterField.Characters" [heading]="t('characters-title')">
<ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template>
</app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.teams" [libraryId]="series.libraryId" [queryParam]="FilterField.Team" [heading]="t('teams-title')">
<ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template>
</app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.locations" [libraryId]="series.libraryId" [queryParam]="FilterField.Location" [heading]="t('locations-title')">
<ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template>
@ -160,6 +143,12 @@
</ng-template>
</app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.translators" [libraryId]="series.libraryId" [queryParam]="FilterField.Translators" [heading]="t('translators-title')">
<ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template>
</app-metadata-detail>
</div>
<div class="row g-0">

View file

@ -92,7 +92,7 @@ export class SeriesMetadataDetailComponent implements OnChanges, OnInit {
+ this.seriesMetadata.letterers.length + this.seriesMetadata.pencillers.length
+ this.seriesMetadata.publishers.length + this.seriesMetadata.characters.length
+ this.seriesMetadata.imprints.length + this.seriesMetadata.translators.length
+ this.seriesMetadata.writers.length) / 11;
+ this.seriesMetadata.writers.length + this.seriesMetadata.teams.length + this.seriesMetadata.locations.length) / 13;
if (sum > 10) {
this.isCollapsed = true;
}
@ -112,7 +112,10 @@ export class SeriesMetadataDetailComponent implements OnChanges, OnInit {
this.seriesMetadata.publishers.length > 0 ||
this.seriesMetadata.characters.length > 0 ||
this.seriesMetadata.imprints.length > 0 ||
this.seriesMetadata.translators.length > 0;
this.seriesMetadata.teams.length > 0 ||
this.seriesMetadata.locations.length > 0 ||
this.seriesMetadata.translators.length > 0
;
this.seriesSummary = (this.seriesMetadata?.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '<br>');

View file

@ -172,6 +172,7 @@ export class LibrarySettingsModalComponent implements OnInit {
this.libraryForm.get(FileTypeGroup.Epub + '')?.setValue(false);
break;
case LibraryType.Comic:
case LibraryType.ComicVine:
this.libraryForm.get(FileTypeGroup.Archive + '')?.setValue(true);
this.libraryForm.get(FileTypeGroup.Images + '')?.setValue(false);
this.libraryForm.get(FileTypeGroup.Pdf + '')?.setValue(false);

View file

@ -483,7 +483,10 @@
"publisher": "Publisher",
"writer": "Writer",
"other": "Other",
"imprint": "Imprint"
"imprint": "Imprint",
"translator": "Translator",
"team": "{{filter-field-pipe.team}}",
"location": "{{filter-field-pipe.location}}"
},
"manga-format-pipe": {
@ -760,6 +763,8 @@
"pencillers-title": "Pencillers",
"publishers-title": "Publishers",
"imprints-title": "Imprints",
"teams-title": "Teams",
"locations-title": "Locations",
"promoted": "{{common.promoted}}",
"see-more": "See More",
@ -937,6 +942,8 @@
"publishers-title": "{{series-metadata-detail.publishers-title}}",
"imprints-title": "{{series-metadata-detail.imprints-title}}",
"tags-title": "{{series-metadata-detail.tags-title}}",
"teams-title": "{{series-metadata-detail.teams-title}}",
"locations-title": "{{series-metadata-detail.locations-title}}",
"not-defined": "Not defined",
"read": "{{common.read}}",
"unread": "Unread",
@ -966,7 +973,9 @@
"inkers-title": "{{series-metadata-detail.inkers-title}}",
"pencillers-title": "{{series-metadata-detail.pencillers-title}}",
"cover-artists-title": "{{series-metadata-detail.cover-artists-title}}",
"editors-title": "{{series-metadata-detail.editors-title}}"
"editors-title": "{{series-metadata-detail.editors-title}}",
"teams-title": "{{series-metadata-detail.teams-title}}",
"locations-title": "{{series-metadata-detail.locations-title}}"
},
"cover-image-chooser": {
@ -1547,7 +1556,6 @@
"import-description": "To get started, import a .cbl file. Kavita will perform multiple checks before importing. Some steps will block moving forward due to issues with the file.",
"validate-description": "All files have been validated to see if there are any operations to do on the list. Any lists have have failed will not move to the next step. Fix the CBL files and retry.",
"validate-warning": "There are issues with the CBL that will prevent an import. Correct these issues then try again.",
"validate-no-issue": "Looks good",
"validate-no-issue-description": "No issues found with CBL, press next.",
"dry-run-description": "This is a dry run and shows what will happen if you press Next and perform the import. All Failures will not be imported.",
"prev": "Prev",
@ -1557,7 +1565,8 @@
"import-step": "Import CBLs",
"validate-cbl-step": "Validate CBL",
"dry-run-step": "Dry Run",
"final-import-step": "Final Step"
"final-import-step": "Final Step",
"comicvine-parsing-label": "Use ComicVine Series matching"
},
"pdf-reader": {
@ -1697,6 +1706,8 @@
"colorist-label": "Colorist",
"character-label": "Character",
"translator-label": "Translator",
"team-label": "{{filter-field-pipe.team}}",
"location-label": "{{filter-field-pipe.location}}",
"language-label": "Language",
"age-rating-label": "Age Rating",
"publication-status-label": "Publication Status",
@ -1946,6 +1957,8 @@
"formats": "Formats",
"genres": "Genres",
"inker": "Inker",
"team": "Team",
"location": "Location",
"languages": "Languages",
"libraries": "Libraries",
"letterer": "Letterer",

View file

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.14.8"
"version": "0.7.14.9"
},
"servers": [
{
@ -1101,6 +1101,10 @@
},
"FileName": {
"type": "string"
},
"comicVineMatching": {
"type": "boolean",
"default": false
}
}
},
@ -1122,6 +1126,9 @@
},
"FileName": {
"style": "form"
},
"comicVineMatching": {
"style": "form"
}
}
}
@ -1191,6 +1198,10 @@
"dryRun": {
"type": "boolean",
"default": false
},
"comicVineMatching": {
"type": "boolean",
"default": false
}
}
},
@ -1215,6 +1226,9 @@
},
"dryRun": {
"style": "form"
},
"comicVineMatching": {
"style": "form"
}
}
}
@ -3204,7 +3218,9 @@
10,
11,
12,
13
13,
14,
15
],
"type": "integer",
"format": "int32"
@ -14316,6 +14332,20 @@
},
"nullable": true
},
"teams": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonDto"
},
"nullable": true
},
"locations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonDto"
},
"nullable": true
},
"genres": {
"type": "array",
"items": {
@ -15615,7 +15645,9 @@
26,
27,
28,
29
29,
30,
31
],
"type": "integer",
"description": "Represents the field which will dictate the value type and the Extension used for filtering",
@ -16548,7 +16580,9 @@
10,
11,
12,
13
13,
14,
15
],
"type": "integer",
"format": "int32"
@ -16594,7 +16628,9 @@
10,
11,
12,
13
13,
14,
15
],
"type": "integer",
"format": "int32"
@ -17789,6 +17825,11 @@
"description": "Highest path (that is under library root) that contains the series.",
"nullable": true
},
"lowestFolderPath": {
"type": "string",
"description": "Lowest path (that is under library root) that contains all files for the series.",
"nullable": true
},
"lastFolderScanned": {
"type": "string",
"description": "Last time the folder was scanned",
@ -18261,6 +18302,12 @@
"translatorLocked": {
"type": "boolean"
},
"teamLocked": {
"type": "boolean"
},
"locationLocked": {
"type": "boolean"
},
"coverArtistLocked": {
"type": "boolean"
},
@ -18394,6 +18441,20 @@
},
"nullable": true
},
"teams": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonDto"
},
"nullable": true
},
"locations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonDto"
},
"nullable": true
},
"ageRating": {
"enum": [
0,
@ -18504,6 +18565,12 @@
"translatorLocked": {
"type": "boolean"
},
"teamLocked": {
"type": "boolean"
},
"locationLocked": {
"type": "boolean"
},
"coverArtistLocked": {
"type": "boolean"
},