Genre, Tags mappings Import & Export (#3959)

Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
This commit is contained in:
Fesaa 2025-08-01 20:22:35 +02:00 committed by GitHub
parent 22058f7413
commit 0770bd344e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 5911 additions and 373 deletions

View File

@ -1288,6 +1288,21 @@ public class ExternalMetadataServiceTests : AbstractDbTest
Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating);
}
[Fact]
public void AgeRating_NormalizedMapping()
{
var tags = new List<string> { "tAg$'1", "tag2" };
var mappings = new Dictionary<string, AgeRating>()
{
["tag1"] = AgeRating.Teen,
};
Assert.Equal(AgeRating.Teen, ExternalMetadataService.DetermineAgeRating(tags, mappings));
mappings.Add("tag2", AgeRating.AdultsOnly);
Assert.Equal(AgeRating.AdultsOnly, ExternalMetadataService.DetermineAgeRating(tags, mappings));
}
#endregion
#region Genres
@ -1600,6 +1615,100 @@ public class ExternalMetadataServiceTests : AbstractDbTest
Assert.Equal(["Boxing"], postSeries.Metadata.Tags.Select(t => t.Title));
}
#endregion
#region FieldMappings
[Fact]
public void GenerateGenreAndTagLists_Normalized_Mappings()
{
var settings = new MetadataSettingsDto
{
EnableExtendedMetadataProcessing = true,
Whitelist = [],
Blacklist = [],
FieldMappings = [
new MetadataFieldMappingDto
{
SourceType = MetadataFieldType.Tag,
SourceValue = "Girls love",
DestinationType = MetadataFieldType.Genre,
DestinationValue = "Yuri",
ExcludeFromSource = false,
},
new MetadataFieldMappingDto
{
SourceType = MetadataFieldType.Tag,
SourceValue = "Girls love",
DestinationType = MetadataFieldType.Genre,
DestinationValue = "Romance",
ExcludeFromSource = false,
},
new MetadataFieldMappingDto
{
SourceType = MetadataFieldType.Genre,
SourceValue = "WW2",
DestinationType = MetadataFieldType.Genre,
DestinationValue = "War",
ExcludeFromSource = true,
},
],
};
var tags = new List<string> { "Girl's Love", "Unrelated tag" };
var genres = new List<string> { "Ww2", "Unrelated genre" };
ExternalMetadataService.GenerateExternalGenreAndTagsList(genres, tags, settings,
out var finalTags, out var finalGenres);
Assert.Contains("Unrelated tag", finalTags);
Assert.Contains("Yuri", finalGenres);
Assert.Contains("Romance", finalGenres);
Assert.Contains("Unrelated genre", finalGenres);
Assert.DoesNotContain("Ww2", finalGenres);
}
[Fact]
public void GenerateGenreAndTagLists_RemoveIfAnyRemoves()
{
var settings = new MetadataSettingsDto
{
EnableExtendedMetadataProcessing = true,
Whitelist = [],
Blacklist = [],
FieldMappings = [
new MetadataFieldMappingDto
{
SourceType = MetadataFieldType.Tag,
SourceValue = "Girls love",
DestinationType = MetadataFieldType.Genre,
DestinationValue = "Yuri",
ExcludeFromSource = false,
},
new MetadataFieldMappingDto
{
SourceType = MetadataFieldType.Tag,
SourceValue = "Girls love",
DestinationType = MetadataFieldType.Genre,
DestinationValue = "Romance",
ExcludeFromSource = true,
},
],
};
var tags = new List<string> { "Girl's Love"};
var genres = new List<string>();
ExternalMetadataService.GenerateExternalGenreAndTagsList(genres, tags, settings,
out var finalTags, out var finalGenres);
Assert.Contains("Yuri", finalGenres);
Assert.Contains("Romance", finalGenres);
Assert.DoesNotContain("Girls Love", finalGenres);
}
#endregion
#region People - Writers/Artists

View File

@ -1,8 +1,10 @@
using System.Collections.Generic;
using System.IO.Abstractions;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.KavitaPlus.Metadata;
using API.Entities;
using API.Entities.Enums;
@ -20,6 +22,11 @@ public class SettingsServiceTests
private readonly ISettingsService _settingsService;
private readonly IUnitOfWork _mockUnitOfWork;
private const string DefaultAgeKey = "default_age";
private const string DefaultFieldSource = "default_source";
private readonly static AgeRating DefaultAgeRating = AgeRating.Everyone;
private readonly static MetadataFieldType DefaultSourceField = MetadataFieldType.Genre;
public SettingsServiceTests()
{
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
@ -30,6 +37,192 @@ public class SettingsServiceTests
Substitute.For<ILogger<SettingsService>>());
}
#region ImportMetadataSettings
[Fact]
public async Task ImportFieldMappings_ReplaceMode()
{
var existingSettings = CreateDefaultMetadataSettingsDto();
var newSettings = new MetadataSettingsDto
{
Whitelist = ["new_whitelist_item"],
Blacklist = ["new_blacklist_item"],
AgeRatingMappings = new Dictionary<string, AgeRating> { ["new_age"] = AgeRating.R18Plus },
FieldMappings =
[
new MetadataFieldMappingDto { Id = 10, SourceValue = "new_source", SourceType = MetadataFieldType.Genre, DestinationValue = "new_dest", DestinationType = MetadataFieldType.Tag }
],
};
var importSettings = new ImportSettingsDto
{
ImportMode = ImportMode.Replace,
Whitelist = true,
Blacklist = true,
AgeRatings = true,
FieldMappings = true,
Resolution = ConflictResolution.Manual,
AgeRatingConflictResolutions = [],
};
var settingsRepo = Substitute.For<ISettingsRepository>();
settingsRepo.GetMetadataSettingDto().Returns(existingSettings);
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
var result = await _settingsService.ImportFieldMappings(newSettings, importSettings);
Assert.True(result.Success);
Assert.Empty(result.AgeRatingConflicts);
Assert.Equal(existingSettings.Whitelist, newSettings.Whitelist);
Assert.Equal(existingSettings.Blacklist, newSettings.Blacklist);
Assert.Equal(existingSettings.AgeRatingMappings, newSettings.AgeRatingMappings);
Assert.Equal(existingSettings.FieldMappings, newSettings.FieldMappings);
}
[Fact]
public async Task ImportFieldMappings_MergeMode_WithNoConflicts()
{
var existingSettingsDto = CreateDefaultMetadataSettingsDto();
var existingSettings = CreateDefaultMetadataSettings();
var newSettings = new MetadataSettingsDto
{
Whitelist = ["new_whitelist_item"],
Blacklist = ["new_blacklist_item"],
AgeRatingMappings = new Dictionary<string, AgeRating> { ["new_age"] = AgeRating.R18Plus },
FieldMappings =
[
new MetadataFieldMappingDto { Id = 10, SourceValue = "new_source", SourceType = MetadataFieldType.Genre, DestinationValue = "new_dest", DestinationType = MetadataFieldType.Tag },
],
};
var importSettings = new ImportSettingsDto
{
ImportMode = ImportMode.Merge,
Whitelist = true,
Blacklist = true,
AgeRatings = true,
FieldMappings = true,
Resolution = ConflictResolution.Manual,
AgeRatingConflictResolutions = [],
};
var settingsRepo = Substitute.For<ISettingsRepository>();
settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto);
settingsRepo.GetMetadataSettings().Returns(existingSettings);
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
var result = await _settingsService.ImportFieldMappings(newSettings, importSettings);
Assert.True(result.Success);
Assert.Empty(result.AgeRatingConflicts);
Assert.Contains("default_white", existingSettingsDto.Whitelist);
Assert.Contains("new_whitelist_item", existingSettingsDto.Whitelist);
Assert.Contains("default_black", existingSettingsDto.Blacklist);
Assert.Contains("new_blacklist_item", existingSettingsDto.Blacklist);
Assert.Equal(2, existingSettingsDto.AgeRatingMappings.Count);
Assert.Equal(2, existingSettingsDto.FieldMappings.Count);
}
[Fact]
public async Task ImportFieldMappings_MergeMode_UseConfiguredOverrides()
{
var existingSettingsDto = CreateDefaultMetadataSettingsDto();
var existingSettings = CreateDefaultMetadataSettings();
var newSettings = new MetadataSettingsDto
{
Whitelist = [],
Blacklist = [],
AgeRatingMappings = new Dictionary<string, AgeRating> { [DefaultAgeKey] = AgeRating.R18Plus },
FieldMappings =
[
new MetadataFieldMappingDto
{
Id = 20,
SourceValue = DefaultFieldSource,
SourceType = DefaultSourceField,
DestinationValue = "different_dest",
DestinationType = MetadataFieldType.Genre,
}
],
};
var importSettings = new ImportSettingsDto
{
ImportMode = ImportMode.Merge,
Whitelist = false,
Blacklist = false,
AgeRatings = true,
FieldMappings = true,
Resolution = ConflictResolution.Manual,
AgeRatingConflictResolutions = new Dictionary<string, ConflictResolution> { [DefaultAgeKey] = ConflictResolution.Replace },
};
var settingsRepo = Substitute.For<ISettingsRepository>();
settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto);
settingsRepo.GetMetadataSettings().Returns(existingSettings);
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
var result = await _settingsService.ImportFieldMappings(newSettings, importSettings);
Assert.True(result.Success);
Assert.Empty(result.AgeRatingConflicts);
Assert.Equal(AgeRating.R18Plus, existingSettingsDto.AgeRatingMappings[DefaultAgeKey]);
}
[Fact]
public async Task ImportFieldMappings_MergeMode_SkipIdenticalMappings()
{
var existingSettingsDto = CreateDefaultMetadataSettingsDto();
var existingSettings = CreateDefaultMetadataSettings();
var newSettings = new MetadataSettingsDto
{
Whitelist = [],
Blacklist = [],
AgeRatingMappings = new Dictionary<string, AgeRating> { ["existing_age"] = AgeRating.Mature }, // Same value
FieldMappings =
[
new MetadataFieldMappingDto
{
Id = 20,
SourceValue = "existing_source",
SourceType = MetadataFieldType.Genre,
DestinationValue = "existing_dest", // Same destination
DestinationType = MetadataFieldType.Tag // Same destination type
}
],
};
var importSettings = new ImportSettingsDto
{
ImportMode = ImportMode.Merge,
Whitelist = false,
Blacklist = false,
AgeRatings = true,
FieldMappings = true,
Resolution = ConflictResolution.Manual,
AgeRatingConflictResolutions = [],
};
var settingsRepo = Substitute.For<ISettingsRepository>();
settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto);
settingsRepo.GetMetadataSettings().Returns(existingSettings);
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
var result = await _settingsService.ImportFieldMappings(newSettings, importSettings);
Assert.True(result.Success);
Assert.Empty(result.AgeRatingConflicts);
}
#endregion
#region UpdateMetadataSettings
[Fact]
@ -289,4 +482,46 @@ public class SettingsServiceTests
}
#endregion
private MetadataSettingsDto CreateDefaultMetadataSettingsDto()
{
return new MetadataSettingsDto
{
Whitelist = ["default_white"],
Blacklist = ["default_black"],
AgeRatingMappings = new Dictionary<string, AgeRating> { ["default_age"] = AgeRating.Everyone },
FieldMappings =
[
new MetadataFieldMappingDto
{
Id = 1,
SourceValue = "default_source",
SourceType = MetadataFieldType.Genre,
DestinationValue = "default_dest",
DestinationType = MetadataFieldType.Tag
},
],
};
}
private MetadataSettings CreateDefaultMetadataSettings()
{
return new MetadataSettings
{
Whitelist = ["default_white"],
Blacklist = ["default_black"],
AgeRatingMappings = new Dictionary<string, AgeRating> { [DefaultAgeKey] = DefaultAgeRating },
FieldMappings =
[
new MetadataFieldMapping
{
Id = 1,
SourceValue = DefaultFieldSource,
SourceType = DefaultSourceField,
DestinationValue = "default_dest",
DestinationType = MetadataFieldType.Tag
},
],
};
}
}

View File

@ -4,6 +4,7 @@ using System.Linq;
using System.Net;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.DTOs.Email;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.Settings;
@ -253,4 +254,24 @@ public class SettingsController : BaseApiController
return BadRequest(ex.Message);
}
}
/// <summary>
/// Import field mappings
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("import-field-mappings")]
public async Task<ActionResult<FieldMappingsImportResultDto>> ImportFieldMappings([FromBody] ImportFieldMappingsDto dto)
{
try
{
return Ok(await _settingsService.ImportFieldMappings(dto.Data, dto.Settings));
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue importing field mappings");
return BadRequest(ex.Message);
}
}
}

View File

@ -0,0 +1,86 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using API.DTOs.KavitaPlus.Metadata;
namespace API.DTOs;
/// <summary>
/// How Kavita should import the new settings
/// </summary>
public enum ImportMode
{
[Description("Replace")]
Replace = 0,
[Description("Merge")]
Merge = 1,
}
/// <summary>
/// How Kavita should resolve conflicts
/// </summary>
public enum ConflictResolution
{
/// <summary>
/// Require the user to override the default
/// </summary>
[Description("Manual")]
Manual = 0,
/// <summary>
/// Keep current value
/// </summary>
[Description("Keep")]
Keep = 1,
/// <summary>
/// Replace with imported value
/// </summary>
[Description("Replace")]
Replace = 2,
}
public sealed record ImportSettingsDto
{
/// <summary>
/// How Kavita should import the new settings
/// </summary>
public ImportMode ImportMode { get; init; }
/// <summary>
/// Default conflict resolution, override with <see cref="AgeRatingConflictResolutions"/> and <see cref="FieldMappingsConflictResolutions"/>
/// </summary>
public ConflictResolution Resolution { get; init; }
/// <summary>
/// Import <see cref="MetadataSettingsDto.Whitelist"/>
/// </summary>
public bool Whitelist { get; init; }
/// <summary>
/// Import <see cref="MetadataSettingsDto.Blacklist"/>
/// </summary>
public bool Blacklist { get; init; }
/// <summary>
/// Import <see cref="MetadataSettingsDto.AgeRatingMappings"/>
/// </summary>
public bool AgeRatings { get; init; }
/// <summary>
/// Import <see cref="MetadataSettingsDto.FieldMappings"/>
/// </summary>
public bool FieldMappings { get; init; }
/// <summary>
/// Override the <see cref="Resolution"/> for specific age ratings
/// </summary>
/// <remarks>Key is the tag</remarks>
public Dictionary<string, ConflictResolution> AgeRatingConflictResolutions { get; init; }
}
public sealed record FieldMappingsImportResultDto
{
public bool Success { get; init; }
/// <summary>
/// Only present if <see cref="Success"/> is true
/// </summary>
public MetadataSettingsDto ResultingMetadataSettings { get; init; }
/// <summary>
/// Keys of the conflicting age ratings mappings
/// </summary>
public List<string> AgeRatingConflicts { get; init; }
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Entities.MetadataMatching;
@ -7,13 +8,18 @@ using NotImplementedException = System.NotImplementedException;
namespace API.DTOs.KavitaPlus.Metadata;
public sealed record MetadataSettingsDto
public sealed record MetadataSettingsDto: FieldMappingsDto
{
/// <summary>
/// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Enable processing of metadata outside K+; e.g. disk and API
/// </summary>
public bool EnableExtendedMetadataProcessing { get; set; }
/// <summary>
/// Allow the Summary to be written
/// </summary>
@ -75,28 +81,11 @@ public sealed record MetadataSettingsDto
/// </summary>
public bool FirstLastPeopleNaming { get; set; }
/// <summary>
/// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching.
/// </summary>
public Dictionary<string, AgeRating> AgeRatingMappings { get; set; }
/// <summary>
/// A list of rules that allow mapping a genre/tag to another genre/tag
/// </summary>
public List<MetadataFieldMappingDto> FieldMappings { get; set; }
/// <summary>
/// A list of overrides that will enable writing to locked fields
/// </summary>
public List<MetadataSettingField> Overrides { get; set; }
/// <summary>
/// Do not allow any Genre/Tag in this list to be written to Kavita
/// </summary>
public List<string> Blacklist { get; set; }
/// <summary>
/// Only allow these Tags to be written to Kavita
/// </summary>
public List<string> Whitelist { get; set; }
/// <summary>
/// Which Roles to allow metadata downloading for
/// </summary>
@ -123,3 +112,30 @@ public sealed record MetadataSettingsDto
return PersonRoles.Contains(character);
}
}
/// <summary>
/// Decoupled from <see cref="MetadataSettingsDto"/> to allow reuse without requiring the full metadata settings in
/// <see cref="ImportFieldMappingsDto"/>
/// </summary>
public record FieldMappingsDto
{
/// <summary>
/// Do not allow any Genre/Tag in this list to be written to Kavita
/// </summary>
public List<string> Blacklist { get; set; }
/// <summary>
/// Only allow these Tags to be written to Kavita
/// </summary>
public List<string> Whitelist { get; set; }
/// <summary>
/// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching.
/// </summary>
public Dictionary<string, AgeRating> AgeRatingMappings { get; set; }
/// <summary>
/// A list of rules that allow mapping a genre/tag to another genre/tag
/// </summary>
public List<MetadataFieldMappingDto> FieldMappings { get; set; }
}

View File

@ -0,0 +1,15 @@
using API.DTOs.KavitaPlus.Metadata;
namespace API.DTOs.Settings;
public sealed record ImportFieldMappingsDto
{
/// <summary>
/// Import settings
/// </summary>
public ImportSettingsDto Settings { get; init; }
/// <summary>
/// Data to import
/// </summary>
public FieldMappingsDto Data { get; init; }
}

View File

@ -0,0 +1,51 @@
using System;
using System.Threading.Tasks;
using API.Entities.History;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.8.8 - If Kavita+ users had Metadata Matching settings already, ensure the new non-Kavita+ system is enabled to match
/// existing experience
/// </summary>
public static class ManualMigrateEnableMetadataMatchingDefault
{
public static async Task Migrate(DataContext context, IUnitOfWork unitOfWork, ILogger<Program> logger)
{
if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateEnableMetadataMatchingDefault"))
{
return;
}
logger.LogCritical("Running ManualMigrateEnableMetadataMatchingDefault migration - Please be patient, this may take some time. This is not an error");
var settings = await unitOfWork.SettingsRepository.GetMetadataSettingDto();
var shouldBeEnabled = settings != null && (settings.Enabled || settings.AgeRatingMappings.Count != 0 ||
settings.Blacklist.Count != 0 || settings.Whitelist.Count != 0 ||
settings.Whitelist.Count != 0 || settings.Blacklist.Count != 0 ||
settings.FieldMappings.Count != 0);
if (shouldBeEnabled && !settings.EnableExtendedMetadataProcessing)
{
var mSettings = await unitOfWork.SettingsRepository.GetMetadataSettings();
mSettings.EnableExtendedMetadataProcessing = shouldBeEnabled;
await unitOfWork.CommitAsync();
}
await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory()
{
Name = "ManualMigrateEnableMetadataMatchingDefault",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
logger.LogCritical("Running ManualMigrateEnableMetadataMatchingDefault migration - Completed. This is not an error");
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -17,7 +17,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -1862,6 +1862,9 @@ namespace API.Data.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("EnableExtendedMetadataProcessing")
.HasColumnType("INTEGER");
b.Property<bool>("EnableGenres")
.HasColumnType("INTEGER");

View File

@ -14,6 +14,11 @@ public class MetadataSettings
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Enable processing of metadata outside K+; e.g. disk and API
/// </summary>
public bool EnableExtendedMetadataProcessing { get; set; }
#region Series Metadata
/// <summary>

View File

@ -681,13 +681,34 @@ public class ExternalMetadataService : IExternalMetadataService
return [.. staff];
}
/// <summary>
/// Helper method, calls <see cref="ProcessGenreAndTagLists"/>
/// </summary>
/// <param name="externalMetadata"></param>
/// <param name="settings"></param>
/// <param name="processedTags"></param>
/// <param name="processedGenres"></param>
private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings,
ref List<string> processedTags, ref List<string> processedGenres)
{
externalMetadata.Tags ??= [];
externalMetadata.Genres ??= [];
GenerateGenreAndTagLists(externalMetadata.Genres, externalMetadata.Tags.Select(t => t.Name).ToList(),
settings, ref processedTags, ref processedGenres);
}
var mappings = ApplyFieldMappings(externalMetadata.Tags.Select(t => t.Name), MetadataFieldType.Tag, settings.FieldMappings);
/// <summary>
/// Run all genres and tags through the Metadata settings
/// </summary>
/// <param name="genres">Genres to process</param>
/// <param name="tags">Tags to process</param>
/// <param name="settings"></param>
/// <param name="processedTags"></param>
/// <param name="processedGenres"></param>
private static void GenerateGenreAndTagLists(IList<string> genres, IList<string> tags, MetadataSettingsDto settings,
ref List<string> processedTags, ref List<string> processedGenres)
{
var mappings = ApplyFieldMappings(tags, MetadataFieldType.Tag, settings.FieldMappings);
if (mappings.TryGetValue(MetadataFieldType.Tag, out var tagsToTags))
{
processedTags.AddRange(tagsToTags);
@ -697,7 +718,7 @@ public class ExternalMetadataService : IExternalMetadataService
processedGenres.AddRange(tagsToGenres);
}
mappings = ApplyFieldMappings(externalMetadata.Genres, MetadataFieldType.Genre, settings.FieldMappings);
mappings = ApplyFieldMappings(genres, MetadataFieldType.Genre, settings.FieldMappings);
if (mappings.TryGetValue(MetadataFieldType.Tag, out var genresToTags))
{
processedTags.AddRange(genresToTags);
@ -711,6 +732,30 @@ public class ExternalMetadataService : IExternalMetadataService
processedGenres = ApplyBlackWhiteList(settings, MetadataFieldType.Genre, processedGenres);
}
/// <summary>
/// Processes the given tags and genres only if <see cref="MetadataSettingsDto.EnableExtendedMetadataProcessing"/>
/// is true, else return without change
/// </summary>
/// <param name="genres"></param>
/// <param name="tags"></param>
/// <param name="settings"></param>
/// <param name="processedTags"></param>
/// <param name="processedGenres"></param>
public static void GenerateExternalGenreAndTagsList(IList<string> genres, IList<string> tags,
MetadataSettingsDto settings, out List<string> processedTags, out List<string> processedGenres)
{
if (!settings.EnableExtendedMetadataProcessing)
{
processedTags = [..tags];
processedGenres = [..genres];
return;
}
processedTags = [];
processedGenres = [];
GenerateGenreAndTagLists(genres, tags, settings, ref processedTags, ref processedGenres);
}
private async Task<bool> UpdateRelationships(Series series, MetadataSettingsDto settings, IList<SeriesRelationship>? externalMetadataRelations, AppUser defaultAdmin)
{
if (!settings.EnableRelationships) return false;
@ -1003,16 +1048,19 @@ public class ExternalMetadataService : IExternalMetadataService
private static List<string> ApplyBlackWhiteList(MetadataSettingsDto settings, MetadataFieldType fieldType, List<string> processedStrings)
{
var whiteList = settings.Whitelist.Select(t => t.ToNormalized()).ToList();
var blackList = settings.Blacklist.Select(t => t.ToNormalized()).ToList();
return fieldType switch
{
MetadataFieldType.Genre => processedStrings.Distinct()
.Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g))
.Where(g => blackList.Count == 0 || !blackList.Contains(g.ToNormalized()))
.ToList(),
MetadataFieldType.Tag => processedStrings.Distinct()
.Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g))
.Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g))
.Where(g => blackList.Count == 0 || !blackList.Contains(g.ToNormalized()))
.Where(g => whiteList.Count == 0 || whiteList.Contains(g.ToNormalized()))
.ToList(),
_ => throw new ArgumentOutOfRangeException(nameof(fieldType), fieldType, null)
_ => throw new ArgumentOutOfRangeException(nameof(fieldType), fieldType, null),
};
}
@ -1718,24 +1766,22 @@ public class ExternalMetadataService : IExternalMetadataService
foreach (var value in values)
{
var mapping = mappings.FirstOrDefault(m =>
var matchingMappings = mappings.Where(m =>
m.SourceType == sourceType &&
m.SourceValue.Equals(value, StringComparison.OrdinalIgnoreCase));
m.SourceValue.ToNormalized().Equals(value.ToNormalized()));
if (mapping != null && !string.IsNullOrWhiteSpace(mapping.DestinationValue))
var keepOriginal = true;
foreach (var mapping in matchingMappings.Where(mapping => !string.IsNullOrWhiteSpace(mapping.DestinationValue)))
{
var targetType = mapping.DestinationType;
result[mapping.DestinationType].Add(mapping.DestinationValue);
if (!mapping.ExcludeFromSource)
{
result[sourceType].Add(mapping.SourceValue);
}
result[targetType].Add(mapping.DestinationValue);
// Only keep the original tags if none of the matches want to remove it
keepOriginal = keepOriginal && !mapping.ExcludeFromSource;
}
else
if (keepOriginal)
{
// If no mapping, keep the original value
result[sourceType].Add(value);
}
}
@ -1760,9 +1806,10 @@ public class ExternalMetadataService : IExternalMetadataService
{
// Find highest age rating from mappings
mappings ??= new Dictionary<string, AgeRating>();
mappings = mappings.ToDictionary(k => k.Key.ToNormalized(), k => k.Value);
return values
.Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown)
.Select(v => mappings.TryGetValue(v.ToNormalized(), out var mapping) ? mapping : AgeRating.Unknown)
.DefaultIfEmpty(AgeRating.Unknown)
.Max();
}

View File

@ -209,12 +209,17 @@ public class SeriesService : ISeriesService
{
var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title));
var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings);
if (updatedRating > series.Metadata.AgeRating)
if (metadataSettings.EnableExtendedMetadataProcessing)
{
series.Metadata.AgeRating = updatedRating;
series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating);
var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings);
if (updatedRating > series.Metadata.AgeRating)
{
series.Metadata.AgeRating = updatedRating;
series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating);
}
}
}
}

View File

@ -1,12 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Entities.MetadataMatching;
using API.Extensions;
using API.Logging;
using API.Services.Tasks.Scanner;
@ -16,12 +19,21 @@ using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SharpCompress.Common;
namespace API.Services;
public interface ISettingsService
{
Task<MetadataSettingsDto> UpdateMetadataSettings(MetadataSettingsDto dto);
/// <summary>
/// Update <see cref="MetadataSettings.Whitelist"/>, <see cref="MetadataSettings.Blacklist"/>, <see cref="MetadataSettings.AgeRatingMappings"/>, <see cref="MetadataSettings.FieldMappings"/>
/// with data from the given dto.
/// </summary>
/// <param name="dto"></param>
/// <param name="settings"></param>
/// <returns></returns>
Task<FieldMappingsImportResultDto> ImportFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings);
Task<ServerSettingDto> UpdateSettings(ServerSettingDto updateSettingsDto);
}
@ -54,6 +66,7 @@ public class SettingsService : ISettingsService
{
var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings();
existingMetadataSetting.Enabled = dto.Enabled;
existingMetadataSetting.EnableExtendedMetadataProcessing = dto.EnableExtendedMetadataProcessing;
existingMetadataSetting.EnableSummary = dto.EnableSummary;
existingMetadataSetting.EnableLocalizedName = dto.EnableLocalizedName;
existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus;
@ -108,6 +121,150 @@ public class SettingsService : ISettingsService
return await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
}
public async Task<FieldMappingsImportResultDto> ImportFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings)
{
if (dto.AgeRatingMappings.Keys.Distinct().Count() != dto.AgeRatingMappings.Count)
{
throw new KavitaException("errors.import-fields.non-unique-age-ratings");
}
if (dto.FieldMappings.DistinctBy(f => f.Id).Count() != dto.FieldMappings.Count)
{
throw new KavitaException("errors.import-fields.non-unique-fields");
}
return settings.ImportMode switch
{
ImportMode.Merge => await MergeFieldMappings(dto, settings),
ImportMode.Replace => await ReplaceFieldMappings(dto, settings),
_ => throw new ArgumentOutOfRangeException(nameof(settings), $"Invalid import mode {nameof(settings.ImportMode)}")
};
}
/// <summary>
/// Will fully replace any enabled fields, always successful
/// </summary>
/// <param name="dto"></param>
/// <param name="settings"></param>
/// <returns></returns>
private async Task<FieldMappingsImportResultDto> ReplaceFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings)
{
var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
if (settings.Whitelist)
{
existingMetadataSetting.Whitelist = dto.Whitelist;
}
if (settings.Blacklist)
{
existingMetadataSetting.Blacklist = dto.Blacklist;
}
if (settings.AgeRatings)
{
existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings;
}
if (settings.FieldMappings)
{
existingMetadataSetting.FieldMappings = dto.FieldMappings;
}
return new FieldMappingsImportResultDto
{
Success = true,
ResultingMetadataSettings = existingMetadataSetting,
AgeRatingConflicts = [],
};
}
/// <summary>
/// Tries to merge all enabled fields, fails if any merge was marked as manual. Always goes through all items
/// </summary>
/// <param name="dto"></param>
/// <param name="settings"></param>
/// <returns></returns>
private async Task<FieldMappingsImportResultDto> MergeFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings)
{
var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
if (settings.Whitelist)
{
existingMetadataSetting.Whitelist = existingMetadataSetting.Whitelist.Union(dto.Whitelist).DistinctBy(d => d.ToNormalized()).ToList();
}
if (settings.Blacklist)
{
existingMetadataSetting.Blacklist = existingMetadataSetting.Blacklist.Union(dto.Blacklist).DistinctBy(d => d.ToNormalized()).ToList();
}
List<string> ageRatingConflicts = [];
if (settings.AgeRatings)
{
foreach (var arm in dto.AgeRatingMappings)
{
if (!existingMetadataSetting.AgeRatingMappings.TryGetValue(arm.Key, out var mapping))
{
existingMetadataSetting.AgeRatingMappings.Add(arm.Key, arm.Value);
continue;
}
if (arm.Value == mapping)
{
continue;
}
var resolution = settings.AgeRatingConflictResolutions.GetValueOrDefault(arm.Key, settings.Resolution);
switch (resolution)
{
case ConflictResolution.Keep: continue;
case ConflictResolution.Replace:
existingMetadataSetting.AgeRatingMappings[arm.Key] = arm.Value;
break;
case ConflictResolution.Manual:
ageRatingConflicts.Add(arm.Key);
break;
default:
throw new ArgumentOutOfRangeException(nameof(settings), $"Invalid conflict resolution {nameof(ConflictResolution)}.");
}
}
}
if (settings.FieldMappings)
{
existingMetadataSetting.FieldMappings = existingMetadataSetting.FieldMappings
.Union(dto.FieldMappings)
.DistinctBy(fm => new
{
fm.SourceType,
SourceValue = fm.SourceValue.ToNormalized(),
fm.DestinationType,
DestinationValue = fm.DestinationValue.ToNormalized(),
})
.ToList();
}
if (ageRatingConflicts.Count > 0)
{
return new FieldMappingsImportResultDto
{
Success = false,
AgeRatingConflicts = ageRatingConflicts,
};
}
return new FieldMappingsImportResultDto
{
Success = true,
ResultingMetadataSettings = existingMetadataSetting,
AgeRatingConflicts = [],
};
}
/// <summary>
/// Update Server Settings
/// </summary>

View File

@ -8,6 +8,7 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Metadata;
using API.Data.Repositories;
using API.DTOs.KavitaPlus.Metadata;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
@ -29,7 +30,7 @@ namespace API.Services.Tasks.Scanner;
public interface IProcessSeries
{
Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library, int totalToProcess, bool forceUpdate = false);
Task ProcessSeriesAsync(MetadataSettingsDto settings, IList<ParserInfo> parsedInfos, Library library, int totalToProcess, bool forceUpdate = false);
}
/// <summary>
@ -70,7 +71,7 @@ public class ProcessSeries : IProcessSeries
}
public async Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library, int totalToProcess, bool forceUpdate = false)
public async Task ProcessSeriesAsync(MetadataSettingsDto settings, IList<ParserInfo> parsedInfos, Library library, int totalToProcess, bool forceUpdate = false)
{
if (!parsedInfos.Any()) return;
@ -116,7 +117,7 @@ public class ProcessSeries : IProcessSeries
// parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort)
var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo);
await UpdateVolumes(series, parsedInfos, forceUpdate);
await UpdateVolumes(settings, series, parsedInfos, forceUpdate);
series.Pages = series.Volumes.Sum(v => v.Pages);
series.NormalizedName = series.Name.ToNormalized();
@ -151,7 +152,7 @@ public class ProcessSeries : IProcessSeries
series.NormalizedLocalizedName = series.LocalizedName.ToNormalized();
}
await UpdateSeriesMetadata(series, library);
await UpdateSeriesMetadata(settings, series, library);
// Update series FolderPath here
await UpdateSeriesFolderPath(parsedInfos, library, series);
@ -288,7 +289,7 @@ public class ProcessSeries : IProcessSeries
}
private async Task UpdateSeriesMetadata(Series series, Library library)
private async Task UpdateSeriesMetadata(MetadataSettingsDto settings, Series series, Library library)
{
series.Metadata ??= new SeriesMetadataBuilder().Build();
var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
@ -311,14 +312,16 @@ public class ProcessSeries : IProcessSeries
{
series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating);
// Get the MetadataSettings and apply Age Rating Mappings here
var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title));
var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings);
if (updatedRating > series.Metadata.AgeRating)
if (settings.EnableExtendedMetadataProcessing)
{
series.Metadata.AgeRating = updatedRating;
var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title));
var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, settings.AgeRatingMappings);
if (updatedRating > series.Metadata.AgeRating)
{
series.Metadata.AgeRating = updatedRating;
}
}
}
DeterminePublicationStatus(series, chapters);
@ -340,16 +343,16 @@ public class ProcessSeries : IProcessSeries
}
#region PeopleAndTagsAndGenres
if (!series.Metadata.WriterLocked)
if (!series.Metadata.WriterLocked)
{
var personSw = Stopwatch.StartNew();
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList();
if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Writer))
{
var personSw = Stopwatch.StartNew();
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList();
if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Writer))
{
await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer);
}
_logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count);
await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer);
}
_logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count);
}
if (!series.Metadata.ColoristLocked)
{
@ -676,7 +679,7 @@ public class ProcessSeries : IProcessSeries
}
}
private async Task UpdateVolumes(Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false)
private async Task UpdateVolumes(MetadataSettingsDto settings, Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false)
{
// Add new volumes and update chapters per volume
var distinctVolumes = parsedInfos.DistinctVolumes();
@ -709,7 +712,7 @@ public class ProcessSeries : IProcessSeries
var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray();
await UpdateChapters(series, volume, infos, forceUpdate);
await UpdateChapters(settings, series, volume, infos, forceUpdate);
volume.Pages = volume.Chapters.Sum(c => c.Pages);
}
@ -746,7 +749,7 @@ public class ProcessSeries : IProcessSeries
series.Volumes = nonDeletedVolumes;
}
private async Task UpdateChapters(Series series, Volume volume, IList<ParserInfo> parsedInfos, bool forceUpdate = false)
private async Task UpdateChapters(MetadataSettingsDto settings, Series series, Volume volume, IList<ParserInfo> parsedInfos, bool forceUpdate = false)
{
// Add new chapters
foreach (var info in parsedInfos)
@ -799,7 +802,7 @@ public class ProcessSeries : IProcessSeries
try
{
await UpdateChapterFromComicInfo(chapter, info.ComicInfo, forceUpdate);
await UpdateChapterFromComicInfo(settings, chapter, info.ComicInfo, forceUpdate);
}
catch (Exception ex)
{
@ -900,7 +903,7 @@ public class ProcessSeries : IProcessSeries
}
}
private async Task UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo, bool forceUpdate = false)
private async Task UpdateChapterFromComicInfo(MetadataSettingsDto settings, Chapter chapter, ComicInfo? comicInfo, bool forceUpdate = false)
{
if (comicInfo == null) return;
var firstFile = chapter.Files.MinBy(x => x.Chapter);
@ -1069,16 +1072,25 @@ public class ProcessSeries : IProcessSeries
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Location);
}
if (!chapter.GenresLocked)
if (!chapter.GenresLocked || !chapter.TagsLocked)
{
var genres = TagHelper.GetTagValues(comicInfo.Genre);
await UpdateChapterGenres(chapter, genres);
}
if (!chapter.TagsLocked)
{
var tags = TagHelper.GetTagValues(comicInfo.Tags);
await UpdateChapterTags(chapter, tags);
ExternalMetadataService.GenerateExternalGenreAndTagsList(genres, tags, settings,
out var finalTags, out var finalGenres);
if (!chapter.GenresLocked)
{
await UpdateChapterGenres(chapter, finalGenres);
}
if (!chapter.TagsLocked)
{
await UpdateChapterTags(chapter, finalTags);
}
}
_logger.LogTrace("[TIME] Kavita took {Time} ms to create/update Chapter: {File}", sw.ElapsedMilliseconds, chapter.Files.First().FileName);

View File

@ -13,6 +13,7 @@ using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services.Plus;
using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner;
using API.Services.Tasks.Scanner.Parser;
@ -316,7 +317,8 @@ public class ScannerService : IScannerService
{
// Process Series
var seriesProcessStopWatch = Stopwatch.StartNew();
await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, seriesLeftToProcess, bypassFolderOptimizationChecks);
var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
await _processSeries.ProcessSeriesAsync(settings, parsedSeries[pSeries], library, seriesLeftToProcess, bypassFolderOptimizationChecks);
_logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, parsedSeries[pSeries][0].Series);
seriesLeftToProcess--;
}
@ -614,6 +616,8 @@ public class ScannerService : IScannerService
var toProcess = new Dictionary<ParsedSeries, IList<ParserInfo>>();
var scanSw = Stopwatch.StartNew();
var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
foreach (var series in parsedSeries)
{
if (!series.Key.HasChanged)
@ -638,22 +642,26 @@ public class ScannerService : IScannerService
var allGenres = toProcess
.SelectMany(s => s.Value
.SelectMany(p => p.ComicInfo?.Genre?
.Split(",", StringSplitOptions.RemoveEmptyEntries) // Split on comma and remove empty entries
.Select(g => g.Trim()) // Trim each genre
.Where(g => !string.IsNullOrWhiteSpace(g)) // Ensure no null/empty genres
?? [])); // Handle null Genre or ComicInfo safely
await CreateAllGenresAsync(allGenres.Distinct().ToList());
.Split(",", StringSplitOptions.RemoveEmptyEntries)
.Select(g => g.Trim())
.Where(g => !string.IsNullOrWhiteSpace(g))
?? []))
.Distinct().ToList();
var allTags = toProcess
.SelectMany(s => s.Value
.SelectMany(p => p.ComicInfo?.Tags?
.Split(",", StringSplitOptions.RemoveEmptyEntries) // Split on comma and remove empty entries
.Select(g => g.Trim()) // Trim each genre
.Where(g => !string.IsNullOrWhiteSpace(g)) // Ensure no null/empty genres
?? [])); // Handle null Tag or ComicInfo safely
.Split(",", StringSplitOptions.RemoveEmptyEntries)
.Select(g => g.Trim())
.Where(g => !string.IsNullOrWhiteSpace(g))
?? []))
.Distinct().ToList();
await CreateAllTagsAsync(allTags.Distinct().ToList());
ExternalMetadataService.GenerateExternalGenreAndTagsList(allGenres, allTags, settings,
out var processedTags, out var processedGenres);
await CreateAllGenresAsync(processedGenres);
await CreateAllTagsAsync(processedTags);
}
var totalFiles = 0;
@ -664,7 +672,7 @@ public class ScannerService : IScannerService
{
totalFiles += pSeries.Value.Count;
var seriesProcessStopWatch = Stopwatch.StartNew();
await _processSeries.ProcessSeriesAsync(pSeries.Value, library, seriesLeftToProcess, forceUpdate);
await _processSeries.ProcessSeriesAsync(settings, pSeries.Value, library, seriesLeftToProcess, forceUpdate);
_logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, pSeries.Value[0].Series);
seriesLeftToProcess--;
}

View File

@ -296,6 +296,9 @@ public class Startup
// v0.8.7
await ManualMigrateReadingProfiles.Migrate(dataContext, logger);
// v0.8.8
await ManualMigrateEnableMetadataMatchingDefault.Migrate(dataContext, unitOfWork, logger);
#endregion
// Update the version in the DB after all migrations are run

View File

@ -13,7 +13,8 @@
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
"style": "scss",
"changeDetection": "OnPush"
},
"@schematics/angular:application": {
"strict": true

View File

@ -541,7 +541,6 @@
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.5.tgz",
"integrity": "sha512-b2cG41r6lilApXLlvja1Ra2D00dM3BxmQhoElKC1tOnpD6S3/krlH1DOnBB2I55RBn9iv4zdmPz1l8zPUSh7DQ==",
"dev": true,
"dependencies": {
"@babel/core": "7.26.9",
"@jridgewell/sourcemap-codec": "^1.4.14",
@ -569,7 +568,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
"dev": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@ -584,7 +582,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
"dev": true,
"engines": {
"node": ">= 14.16.0"
},
@ -4906,8 +4903,7 @@
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
"node_modules/cosmiconfig": {
"version": "8.3.6",
@ -5354,7 +5350,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
@ -5364,7 +5359,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@ -8181,8 +8175,7 @@
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
},
"node_modules/replace-in-file": {
"version": "7.1.0",
@ -8403,7 +8396,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true
"devOptional": true
},
"node_modules/sass": {
"version": "1.85.0",
@ -8468,7 +8461,6 @@
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
@ -9093,7 +9085,6 @@
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -0,0 +1,32 @@
import {MetadataSettings} from "../admin/_models/metadata-settings";
export enum ImportMode {
Replace = 0,
Merge = 1,
}
export const ImportModes = [ImportMode.Replace, ImportMode.Merge];
export enum ConflictResolution {
Manual = 0,
Keep = 1,
Replace = 2,
}
export const ConflictResolutions = [ConflictResolution.Manual, ConflictResolution.Keep, ConflictResolution.Replace];
export interface ImportSettings {
importMode: ImportMode;
resolution: ConflictResolution;
whitelist: boolean;
blacklist: boolean;
ageRatings: boolean;
fieldMappings: boolean;
ageRatingConflictResolutions: Record<string, ConflictResolution>;
}
export interface FieldMappingsImportResult {
success: boolean;
resultingMetadataSettings: MetadataSettings;
ageRatingConflicts: string[];
}

View File

@ -0,0 +1,26 @@
import {Pipe, PipeTransform} from '@angular/core';
import {translate} from "@jsverse/transloco";
import {ConflictResolution} from "../_models/import-field-mappings";
@Pipe({
name: 'conflictResolution'
})
export class ConflictResolutionPipe implements PipeTransform {
transform(value: ConflictResolution | null | string): string {
if (typeof value === 'string') {
value = parseInt(value, 10);
}
switch (value) {
case ConflictResolution.Manual:
return translate('import-mappings.manual');
case ConflictResolution.Keep:
return translate('import-mappings.keep');
case ConflictResolution.Replace:
return translate('import-mappings.replace');
}
return translate('common.unknown');
}
}

View File

@ -0,0 +1,25 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@jsverse/transloco";
import {ImportMode} from "../_models/import-field-mappings";
@Pipe({
name: 'importMode'
})
export class ImportModePipe implements PipeTransform {
transform(value: ImportMode | null | string): string {
if (typeof value === 'string') {
value = parseInt(value, 10);
}
switch (value) {
case ImportMode.Replace:
return translate('import-mappings.replace');
case ImportMode.Merge:
return translate('import-mappings.merge');
}
return translate('common.unknown');
}
}

View File

@ -0,0 +1,25 @@
import {Pipe, PipeTransform} from '@angular/core';
import {MetadataFieldType} from "../admin/_models/metadata-settings";
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'metadataFieldType'
})
export class MetadataFieldTypePipe implements PipeTransform {
transform(value: MetadataFieldType | null | string): string {
if (typeof value === 'string') {
value = parseInt(value, 10);
}
switch (value) {
case MetadataFieldType.Genre:
return translate('manage-metadata-settings.genre');
case MetadataFieldType.Tag:
return translate('manage-metadata-settings.tag');
default:
return translate('common.unknown');
}
}
}

View File

@ -4,6 +4,7 @@ import {catchError, map, ReplaySubject, tap, throwError} from "rxjs";
import {environment} from "../../environments/environment";
import {TextResonse} from '../_types/text-response';
import {LicenseInfo} from "../_models/kavitaplus/license-info";
import {toSignal} from "@angular/core/rxjs-interop";
@Injectable({
providedIn: 'root'
@ -18,6 +19,7 @@ export class LicenseService {
* Does the user have an active license
*/
public readonly hasValidLicense$ = this.hasValidLicenseSource.asObservable();
public readonly hasValidLicenseSignal = toSignal(this.hasValidLicense$, {initialValue: false});
/**

View File

@ -18,6 +18,7 @@ export interface MetadataFieldMapping {
export interface MetadataSettings {
enabled: boolean;
enableExtendedMetadataProcessing: boolean;
enableSummary: boolean;
enablePublicationStatus: boolean;
enableRelationships: boolean;
@ -36,7 +37,7 @@ export interface MetadataSettings {
enableGenres: boolean;
enableTags: boolean;
firstLastPeopleNaming: boolean;
ageRatingMappings: Map<string, AgeRating>;
ageRatingMappings: Record<string, AgeRating>;
fieldMappings: Array<MetadataFieldMapping>;
blacklist: Array<string>;
whitelist: Array<string>;

View File

@ -0,0 +1,171 @@
<ng-container *transloco="let t; prefix: 'import-mappings'">
<div class="row g-0" style="min-width: 135px;">
<app-step-tracker [steps]="steps" [currentStep]="currentStepIndex()"></app-step-tracker>
</div>
<app-loading [loading]="isLoading()" />
@if (!isLoading()) {
<div>
@switch (currentStepIndex()) {
@case (Step.Import) {
<div class="row g-0">
<p>{{t('import-description')}}</p>
<form [formGroup]="uploadForm" enctype="multipart/form-data">
<file-upload [multiple]="false" formControlName="files"></file-upload>
</form>
</div>
}
@case (Step.Configure) {
<form class="row" [formGroup]="importSettingsForm">
<div class="col-md-6 col-sm-12">
@if (importSettingsForm.get('importMode'); as control) {
<app-setting-item [control]="control" [canEdit]="false" [showEdit]="false" [title]="t('import-mode-label')" [subtitle]="t('import-mode-tooltip')">
<ng-template #view>
<select formControlName="importMode" class="form-control">
@for (mode of ImportModes; track mode) {
<option [ngValue]="mode">{{mode | importMode}}</option>
}
</select>
</ng-template>
</app-setting-item>
}
</div>
<div class="col-md-6 col-sm-12">
@if (importSettingsForm.get('resolution'); as control) {
<app-setting-item [control]="control" [canEdit]="false" [showEdit]="false" [title]="t('resolution-label')" [subtitle]="t('resolution-tooltip')">
<ng-template #view>
<select formControlName="resolution" class="form-control">
@for (resolution of ConflictResolutions; track resolution) {
<option [ngValue]="resolution">{{resolution | conflictResolution}}</option>
}
</select>
</ng-template>
</app-setting-item>
}
</div>
<div class="row">
<div class="conflict-group-title pt-4">{{t('fields-to-import')}}</div>
<div class="text-muted">{{t('fields-to-import-tooltip')}}</div>
<div class="col-md-6 col-sm-12 pt-4">
@if (importSettingsForm.get('whitelist'); as control) {
<app-setting-switch [title]="t('whitelist-label')">
<ng-template #switch>
<div class="form-check form-switch">
<input id="whitelist-enabled" type="checkbox" class="form-check-input" formControlName="whitelist">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="col-md-6 col-sm-12 pt-4">
@if (importSettingsForm.get('blacklist'); as control) {
<app-setting-switch [title]="t('blacklist-label')">
<ng-template #switch>
<div class="form-check form-switch">
<input id="blacklist-enabled" type="checkbox" class="form-check-input" formControlName="blacklist">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="col-md-6 col-sm-12 pt-4">
@if (importSettingsForm.get('ageRatings'); as control) {
<app-setting-switch [title]="t('age-ratings-label')">
<ng-template #switch>
<div class="form-check form-switch">
<input id="age-ratings-enabled" type="checkbox" class="form-check-input" formControlName="ageRatings">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="col-md-6 col-sm-12 pt-4">
@if (importSettingsForm.get('fieldMappings'); as control) {
<app-setting-switch [title]="t('field-mappings-label')">
<ng-template #switch>
<div class="form-check form-switch">
<input id="field-mappings-enabled" type="checkbox" class="form-check-input" formControlName="fieldMappings">
</div>
</ng-template>
</app-setting-switch>
}
</div>
</div>
</form>
}
@case (Step.Conflicts) {
@let res = importResult();
@if (res) {
<form class="row" [formGroup]="importSettingsForm">
@if (res.ageRatingConflicts.length > 0) {
<div class="conflict-group-title">{{t('age-ratings-label')}}</div>
<div class="text-muted">{{t('age-ratings-conflicts-tooltip')}}</div>
}
@for (arm of res.ageRatingConflicts; track arm) {
<div class="col-md-6 col-sm-12 pt-4">
@if (importSettingsForm.get('ageRatingConflictResolutions.' + arm); as control) {
<div formGroupName="ageRatingConflictResolutions">
<span class="conflict-title">{{arm}}</span>
<select [formControlName]="arm" class="form-control mt-2">
@for (resolution of ConflictResolutions; track resolution) {
<option [ngValue]="resolution">
<ng-container [ngTemplateOutlet]="ageRatingConflict"
[ngTemplateOutletContext]="{$implicit: arm, resolution: resolution }" >
</ng-container>
</option>
}
</select>
</div>
}
</div>
}
</form>
}
}
@case (Step.Finalize) {
@let res = importResult();
@if (res) {
<app-manage-metadata-mappings
[settings]="res.resultingMetadataSettings"
[settingsForm]="mappingsForm"
[showHeader]="false">
</app-manage-metadata-mappings>
}
}
}
</div>
}
<div class="modal-footer mt-3">
<div class="col-auto ms-1">
<button type="button" class="btn btn-secondary" (click)="prevStep()" [disabled]="!canMoveToPrevStep()">{{t('prev')}}</button>
</div>
<div class="col-auto ms-1">
<button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(nextButtonLabel())}}</button>
</div>
</div>
</ng-container>
<ng-template #ageRatingConflict let-arm let-resolution='resolution'>
@let oldValue = settings()!.ageRatingMappings[arm];
@let newValue = importedMappings()!.ageRatingMappings[arm];
@switch (resolution) {
@case (ConflictResolution.Manual) { {{'import-mappings.to-pick' | transloco}} }
@case (ConflictResolution.Keep) { {{ oldValue | ageRating }} }
@case (ConflictResolution.Replace) { {{ newValue | ageRating }} }
}
</ng-template>

View File

@ -0,0 +1,50 @@
.file-input {
display: none;
}
.heading-badge {
color: var(--bs-badge-color);
}
::ng-deep .file-info {
width: 83%;
float: left;
}
::ng-deep .file-buttons {
float: right;
}
file-upload {
background: none;
height: auto;
}
::ng-deep .upload-input {
color: var(--input-text-color) !important;
}
::ng-deep file-upload-list-item {
color: var(--input-text-color) !important;
}
.conflict-group-title {
font-size: 1.5rem;
font-weight: bold;
color: var(--accent-text-color);
}
.conflict-title {
font-size: 1.2rem;
color: var(--primary-color);
}
.reset-color {
color: var(--accent-text-color);
}
.break {
height: 1px;
background-color: var(--setting-break-color);
margin: 10px 0;
}

View File

@ -0,0 +1,307 @@
import {Component, computed, inject, OnInit, signal, ViewChild} from '@angular/core';
import {translate, TranslocoDirective, TranslocoPipe} from "@jsverse/transloco";
import {StepTrackerComponent, TimelineStep} from "../../reading-list/_components/step-tracker/step-tracker.component";
import {WikiLink} from "../../_models/wiki";
import {
AbstractControl,
FormArray,
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
ValidatorFn,
Validators
} from "@angular/forms";
import {FileUploadComponent, FileUploadValidators} from "@iplab/ngx-file-upload";
import {MetadataSettings} from "../_models/metadata-settings";
import {SettingsService} from "../settings.service";
import {
ManageMetadataMappingsComponent,
MetadataMappingsExport
} from "../manage-metadata-mappings/manage-metadata-mappings.component";
import {ToastrService} from "ngx-toastr";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {ImportModePipe} from "../../_pipes/import-mode.pipe";
import {ConflictResolutionPipe} from "../../_pipes/conflict-resolution.pipe";
import {
ConflictResolution,
ConflictResolutions,
FieldMappingsImportResult,
ImportMode,
ImportModes,
ImportSettings
} from "../../_models/import-field-mappings";
import {firstValueFrom, switchMap} from "rxjs";
import {tap} from "rxjs/operators";
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
import {NgTemplateOutlet} from "@angular/common";
import {Router} from "@angular/router";
import {LicenseService} from "../../_services/license.service";
import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component";
enum Step {
Import = 0,
Configure = 1,
Conflicts = 2,
Finalize = 3,
}
@Component({
selector: 'app-import-mappings',
imports: [
TranslocoDirective,
StepTrackerComponent,
FileUploadComponent,
FormsModule,
ReactiveFormsModule,
LoadingComponent,
SettingSwitchComponent,
SettingItemComponent,
ImportModePipe,
ConflictResolutionPipe,
AgeRatingPipe,
NgTemplateOutlet,
TranslocoPipe,
ManageMetadataMappingsComponent,
],
templateUrl: './import-mappings.component.html',
styleUrl: './import-mappings.component.scss'
})
export class ImportMappingsComponent implements OnInit {
private readonly router = inject(Router);
private readonly licenseService = inject(LicenseService);
private readonly settingsService = inject(SettingsService);
private readonly toastr = inject(ToastrService);
@ViewChild(ManageMetadataMappingsComponent) manageMetadataMappingsComponent!: ManageMetadataMappingsComponent;
steps: TimelineStep[] = [
{title: translate('import-mappings.import-step'), index: Step.Import, active: true, icon: 'fa-solid fa-file-arrow-up'},
{title: translate('import-mappings.configure-step'), index: Step.Configure, active: false, icon: 'fa-solid fa-gears'},
{title: translate('import-mappings.conflicts-step'), index: Step.Conflicts, active: false, icon: 'fa-solid fa-hammer'},
{title: translate('import-mappings.finalize-step'), index: Step.Finalize, active: false, icon: 'fa-solid fa-floppy-disk'},
];
currentStepIndex = signal(this.steps[0].index);
fileUploadControl = new FormControl<undefined | Array<File>>(undefined, [
FileUploadValidators.accept(['.json']), FileUploadValidators.filesLimit(1)
]);
uploadForm = new FormGroup({
files: this.fileUploadControl,
});
importSettingsForm = new FormGroup({
importMode: new FormControl(ImportMode.Merge, [Validators.required]),
resolution: new FormControl(ConflictResolution.Manual),
whitelist: new FormControl(true),
blacklist: new FormControl(true),
ageRatings: new FormControl(true),
fieldMappings: new FormControl(true),
ageRatingConflictResolutions: new FormGroup({}),
});
/**
* This is that contains the data in the finalize step
*/
mappingsForm = new FormGroup({});
isLoading = signal(false);
settings = signal<MetadataSettings | undefined>(undefined)
importedMappings = signal<MetadataMappingsExport | undefined>(undefined);
importResult = signal<FieldMappingsImportResult | undefined>(undefined);
nextButtonLabel = computed(() => {
switch(this.currentStepIndex()) {
case Step.Configure:
case Step.Conflicts:
return 'import';
case Step.Finalize:
return 'save';
default:
return 'next';
}
});
canMoveToNextStep = computed(() => {
switch (this.currentStepIndex()) {
case Step.Import:
return this.isFileSelected();
case Step.Finalize:
case Step.Configure:
return true;
case Step.Conflicts:
return this.importSettingsForm.valid;
default:
return false;
}
});
canMoveToPrevStep = computed(() => {
switch (this.currentStepIndex()) {
case Step.Import:
return false;
default:
return true;
}
});
ngOnInit(): void {
this.settingsService.getMetadataSettings().subscribe((settings) => {
this.settings.set(settings);
});
}
async nextStep() {
if (this.currentStepIndex() === Step.Import && !this.isFileSelected()) return;
this.isLoading.set(true);
try {
switch(this.currentStepIndex()) {
case Step.Import:
await this.validateImport();
break;
case Step.Conflicts:
case Step.Configure:
await this.tryImport();
break;
case Step.Finalize:
this.save();
}
} catch (error) {
/** Swallow **/
}
this.isLoading.set(false);
}
save() {
const res = this.importResult();
if (!res) return;
const newSettings = res.resultingMetadataSettings;
const data = this.manageMetadataMappingsComponent.packData();
// Update settings with data from the final step
newSettings.whitelist = data.whitelist;
newSettings.blacklist = data.blacklist;
newSettings.ageRatingMappings = data.ageRatingMappings;
newSettings.fieldMappings = data.fieldMappings;
this.settingsService.updateMetadataSettings(newSettings).subscribe({
next: () => {
const fragment = this.licenseService.hasValidLicenseSignal()
? SettingsTabId.Metadata : SettingsTabId.ManageMetadata;
this.router.navigate(['settings'], { fragment: fragment });
}
});
}
async tryImport() {
const data = this.importedMappings();
if (!data) {
this.toastr.error(translate('import-mappings.file-no-valid-content'));
return Promise.resolve();
}
const settings = this.importSettingsForm.value as ImportSettings;
return firstValueFrom(this.settingsService.importFieldMappings(data, settings).pipe(
tap((res) => this.importResult.set(res)),
switchMap((res) => {
return this.settingsService.getMetadataSettings().pipe(
tap(dto => this.settings.set(dto)),
tap(() => {
if (res.success) {
this.currentStepIndex.set(Step.Finalize);
return;
}
this.setupSettingConflicts(res);
this.currentStepIndex.set(Step.Conflicts);
}),
)}),
));
}
async validateImport() {
const files = this.fileUploadControl.value;
if (!files || files.length === 0) {
this.toastr.error(translate('import-mappings.select-files-warning'));
return;
}
const file = files[0];
let newImport: MetadataMappingsExport;
try {
newImport = JSON.parse(await file.text()) as MetadataMappingsExport;
} catch (error) {
this.toastr.error(translate('import-mappings.invalid-file'));
return;
}
if (!newImport.fieldMappings && !newImport.ageRatingMappings && !newImport.blacklist && !newImport.whitelist) {
this.toastr.error(translate('import-mappings.file-no-valid-content'));
return;
}
this.importedMappings.set(newImport);
this.currentStepIndex.update(x=>x + 1);
}
private setupSettingConflicts(res: FieldMappingsImportResult) {
const ageRatingGroup = this.importSettingsForm.get('ageRatingConflictResolutions')! as FormGroup;
for (let key of res.ageRatingConflicts) {
if (!ageRatingGroup.get(key)) {
ageRatingGroup.addControl(key, new FormControl(ConflictResolution.Manual, [this.notManualValidator()]))
}
}
}
private notManualValidator(): ValidatorFn {
return (control: AbstractControl) => {
const value = control.value;
try {
if (parseInt(value, 10) !== ConflictResolution.Manual) return null;
} catch (e) {
}
return {'notManualValidator': {'value': value}}
}
}
prevStep() {
if (this.currentStepIndex() === Step.Import) return;
if (this.currentStepIndex() === Step.Finalize) {
if (this.importResult()!.ageRatingConflicts.length === 0) {
this.currentStepIndex.set(Step.Configure);
} else {
this.currentStepIndex.set(Step.Conflicts);
}
return;
}
this.currentStepIndex.update(x => x - 1);
// Reset when returning to the first step
if (this.currentStepIndex() === Step.Import) {
this.fileUploadControl.reset();
(this.importSettingsForm.get('ageRatingConflictResolutions') as FormArray).clear();
}
}
isFileSelected() {
const files = this.uploadForm.get('files')?.value;
return files && files.length === 1;
}
protected readonly Step = Step;
protected readonly WikiLink = WikiLink;
protected readonly ImportModes = ImportModes;
protected readonly ConflictResolutions = ConflictResolutions;
protected readonly ConflictResolution = ConflictResolution;
}

View File

@ -0,0 +1,151 @@
<ng-container *transloco="let t; prefix: 'manage-metadata-settings'" [formGroup]="settingsForm()">
<div class="row g-0 align-items-start mb-4">
<div class="col">
@if(settingsForm().get('blacklist'); as formControl) {
<app-setting-item
[title]="t('blacklist-label')"
[subtitle]="t('blacklist-tooltip')">
<ng-template #view>
@let val = breakTags(formControl.value);
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>
<ng-template #edit>
<textarea rows="3" id="blacklist" class="form-control" formControlName="blacklist"></textarea>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm().get('whitelist'); as formControl) {
<app-setting-item [title]="t('whitelist-label')" [subtitle]="t('whitelist-tooltip')">
<ng-template #view>
@let val = breakTags(formControl.value);
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>s
<ng-template #edit>
<textarea rows="3" id="whitelist" class="form-control" formControlName="whitelist"></textarea>
</ng-template>
</app-setting-item>
}
</div>
<div class="setting-section-break"></div>
<h4 id="age-rating-header">{{t('age-rating-mapping-title')}}</h4>
<p>{{t('age-rating-mapping-description')}}</p>
<div formArrayName="ageRatingMappings">
@for(mapping of ageRatingMappings.controls; track mapping; let i = $index) {
<div [formGroupName]="i" class="row mb-2">
<div class="col-md-4 d-flex align-items-center justify-content-center">
<input id="age-rating-{{i}}" type="text" class="form-control" formControlName="str" autocomplete="off" />
</div>
<div class="col-md-2 d-flex align-items-center justify-content-center">
<i class="fa fa-arrow-right" aria-hidden="true"></i>
</div>
<div class="col-md-4 d-flex align-items-center justify-content-center">
<select class="form-select" formControlName="rating">
@for (ageRating of ageRatings(); track ageRating.value) {
<option [ngValue]="ageRating.value">
{{ageRating.value | ageRating}}
</option>
}
</select>
</div>
<div class="col-md-2">
<button [attr.aria-label]="'age-rating-' + i" class="btn btn-icon" (click)="removeAgeRatingMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove-age-rating-mapping-label')}}</span>
</button>
@if($last) {
<button [attr.aria-label]="'age-rating-header'" class="btn btn-icon" (click)="addAgeRatingMapping()">
<i class="fa fa-plus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('add-age-rating-mapping-label')}}</span>
</button>
}
</div>
</div>
} @empty {
<button [attr.aria-label]="'age-rating-header'" class="btn btn-secondary" (click)="addAgeRatingMapping()">
<i class="fa fa-plus me-1" aria-hidden="true"></i>{{t('add-age-rating-mapping-label')}}
</button>
}
</div>
<div class="setting-section-break"></div>
<h4 id="field-mapping-header">{{t('field-mapping-title')}}</h4>
<p>{{t('field-mapping-description')}}</p>
<div formArrayName="fieldMappings">
@for (mapping of fieldMappings.controls; track mapping; let i = $index) {
<div [formGroupName]="i" class="row mb-2">
<div class="col-md-2">
<select class="form-select" formControlName="sourceType">
<option [ngValue]="MetadataFieldType.Genre">{{t('genre')}}</option>
<option [ngValue]="MetadataFieldType.Tag">{{t('tag')}}</option>
</select>
</div>
<div class="col-md-2">
<input id="field-mapping-{{i}}" type="text" class="form-control" formControlName="sourceValue"
[placeholder]="t('source-genre-tags-placeholder')" />
</div>
<div class="col-md-2">
<select class="form-select" formControlName="destinationType">
<option [ngValue]="MetadataFieldType.Genre">{{t('genre')}}</option>
<option [ngValue]="MetadataFieldType.Tag">{{t('tag')}}</option>
</select>
</div>
<div class="col-md-2">
<input type="text" class="form-control" formControlName="destinationValue"
[placeholder]="t('dest-genre-tags-placeholder')" />
</div>
<div class="col-md-2">
<div class="form-check">
<input id="remove-source-tag-{{i}}" type="checkbox" class="form-check-input"
formControlName="excludeFromSource">
<label [for]="'remove-source-tag-' + i" class="form-check-label">
{{t('remove-source-tag-label')}}
</label>
</div>
</div>
<div class="col-md-2">
<button [attr.aria-label]="'field-mapping-' + i" class="btn btn-icon" (click)="removeFieldMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove-field-mapping-label')}}</span>
</button>
@if ($last) {
<button [attr.aria-label]="'field-mapping-header'" class="btn btn-icon" (click)="addFieldMapping()">
<i class="fa fa-plus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('add-field-mapping-label')}}</span>
</button>
}
</div>
</div>
} @empty {
<button [attr.aria-label]="'field-mapping-header'" class="btn btn-secondary" (click)="addFieldMapping()">
<i class="fa fa-plus me-1" aria-hidden="true"></i>{{t('add-field-mapping-label')}}
</button>
}
</div>
</ng-container>

View File

@ -0,0 +1,3 @@
.text-muted {
font-size: 0.875rem;
}

View File

@ -0,0 +1,165 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, input, OnInit, signal} from '@angular/core';
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
import {MetadataFieldMapping, MetadataFieldType, MetadataSettings} from "../_models/metadata-settings";
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
import {MetadataService} from "../../_services/metadata.service";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {AgeRating} from "../../_models/metadata/age-rating";
import {DownloadService} from "../../shared/_services/download.service";
export type MetadataMappingsExport = {
ageRatingMappings: Record<string, AgeRating>,
fieldMappings: Array<MetadataFieldMapping>,
blacklist: Array<string>,
whitelist: Array<string>,
}
@Component({
selector: 'app-manage-metadata-mappings',
imports: [
AgeRatingPipe,
DefaultValuePipe,
FormsModule,
ReactiveFormsModule,
SettingItemComponent,
TagBadgeComponent,
TranslocoDirective,
],
templateUrl: './manage-metadata-mappings.component.html',
styleUrl: './manage-metadata-mappings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ManageMetadataMappingsComponent implements OnInit {
private readonly downloadService = inject(DownloadService);
private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly fb = inject(FormBuilder);
/**
* The FormGroup to use, this component will add its own controls
*/
settingsForm = input.required<FormGroup>();
settings = input.required<MetadataSettings>()
/**
* If we should display the extended metadata processing toggle and export button
*/
showHeader = input(true);
ageRatings = signal<Array<AgeRatingDto>>([]);
ageRatingMappings = this.fb.array<FormGroup<{
str: FormControl<string | null>,
rating: FormControl<AgeRating | null>
}>>([]);
fieldMappings = this.fb.array<FormGroup<{
id: FormControl<number | null>
sourceType: FormControl<MetadataFieldType | null>,
destinationType: FormControl<MetadataFieldType | null>,
sourceValue: FormControl<string | null>,
destinationValue: FormControl<string | null>,
excludeFromSource: FormControl<boolean | null>,
}>>([]);
ngOnInit(): void {
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings.set(ratings);
});
const settings = this.settings();
const settingsForm = this.settingsForm();
settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), []));
settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), []));
settingsForm.addControl('ageRatingMappings', this.ageRatingMappings);
settingsForm.addControl('fieldMappings', this.fieldMappings);
if (settings.ageRatingMappings) {
Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => {
this.addAgeRatingMapping(str, rating);
});
}
if (settings.fieldMappings) {
settings.fieldMappings.forEach(mapping => {
this.addFieldMapping(mapping);
});
}
this.cdRef.markForCheck();
}
breakTags(csString: string) {
if (csString) {
return csString.split(',');
}
return [];
}
public packData(): MetadataMappingsExport {
const ageRatingMappings = this.ageRatingMappings.controls.reduce((acc: Record<string, AgeRating>, control) => {
const { str, rating } = control.value;
if (str && rating) {
acc[str] = rating;
}
return acc;
}, {});
const fieldMappings = this.fieldMappings.controls
.map((control) => control.value as MetadataFieldMapping)
.filter(m => m.sourceValue.length > 0 && m.destinationValue.length > 0);
const blacklist = (this.settingsForm().get('blacklist')?.value || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0);
const whitelist = (this.settingsForm().get('whitelist')?.value || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0);
return {
ageRatingMappings: ageRatingMappings,
fieldMappings: fieldMappings,
blacklist: blacklist,
whitelist: whitelist,
}
}
export() {
const data = this.packData();
this.downloadService.downloadObjectAsJson(data, translate('manage-metadata-settings.export-file-name'))
}
addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) {
const mappingGroup = this.fb.group({
str: [str, Validators.required],
rating: [rating, Validators.required]
});
this.ageRatingMappings.push(mappingGroup);
}
removeAgeRatingMappingRow(index: number) {
this.ageRatingMappings.removeAt(index);
}
addFieldMapping(mapping: MetadataFieldMapping | null = null) {
const mappingGroup = this.fb.group({
id: [mapping?.id || 0],
sourceType: [mapping?.sourceType || MetadataFieldType.Genre, Validators.required],
destinationType: [mapping?.destinationType || MetadataFieldType.Genre, Validators.required],
sourceValue: [mapping?.sourceValue || '', Validators.required],
destinationValue: [mapping?.destinationValue || ''],
excludeFromSource: [mapping?.excludeFromSource || false]
});
this.fieldMappings.push(mappingGroup);
}
removeFieldMappingRow(index: number) {
this.fieldMappings.removeAt(index);
}
protected readonly MetadataFieldType = MetadataFieldType;
}

View File

@ -1,22 +1,54 @@
<ng-container *transloco="let t; read:'manage-metadata-settings'">
<p>{{t('description')}}</p>
@if (isLoaded) {
<form [formGroup]="settingsForm">
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enabled'); as formControl) {
<app-setting-switch [title]="t('enabled-label')" [subtitle]="t('enabled-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enabled" type="checkbox" class="form-check-input" formControlName="enabled">
</div>
</ng-template>
</app-setting-switch>
}
<div class="col-md-6 col-sm-12">
@if(settingsForm.get('enabled'); as formControl) {
<app-setting-switch [title]="t('enabled-label')" [subtitle]="t('enabled-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enabled" type="checkbox" class="form-check-input" formControlName="enabled">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="col-md-6 col-sm-12">
@if (settingsForm.get('enableExtendedMetadataProcessing'); as control) {
<app-setting-switch [title]="t('enable-extended-metadata-processing-label')" [subtitle]="t('enable-extended-metadata-processing-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-extended-metadata-processing" type="checkbox" class="form-check-input" formControlName="enableExtendedMetadataProcessing">
</div>
</ng-template>
</app-setting-switch>
}
</div>
</div>
<div class="row g-0 mt-4 mb-4">
<div class="col-md-6 col-sm-12">
<button class="btn btn-secondary" (click)="manageMetadataMappingsComponent.export()">
{{t('export-settings')}}
</button>
<div class="text-muted mt-2">{{t('export-tooltip')}}</div>
</div>
<div class="col-md-6 col-sm-12">
<button class="btn btn-secondary" routerLink="/settings" [fragment]="SettingsTabId.MappingsImport">
{{t('import-settings')}}
</button>
<div class="text-muted mt-2">{{t('import-tooltip')}}</div>
</div>
</div>
<div class="setting-section-break"></div>
<h4>{{t('series-header')}}</h4>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableSummary'); as formControl) {
<app-setting-switch [title]="t('summary-label')" [subtitle]="t('summary-tooltip')">
@ -91,8 +123,7 @@
<div class="setting-section-break"></div>
<!-- Chapter-based fields -->
<h5>{{t('chapter-header')}}</h5>
<h4>{{t('chapter-header')}}</h4>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableChapterTitle'); as formControl) {
<app-setting-switch [title]="t('enable-chapter-title-label')" [subtitle]="t('enable-chapter-title-tooltip')">
@ -155,6 +186,7 @@
@if(settingsForm.get('enablePeople'); as formControl) {
<div class="setting-section-break"></div>
<h4>{{t('people-header')}}</h4>
<div class="row g-0 mt-4 mb-4">
@ -195,13 +227,10 @@
}
}
<div class="setting-section-break"></div>
<div class="row g-0 mt-4 mb-4">
<h4>{{t('tags-header')}}</h4>
<div class="row mt-4 mb-4">
<div class="col-md-6">
@if(settingsForm.get('enableGenres'); as formControl) {
<app-setting-switch [title]="t('enable-genres-label')" [subtitle]="t('enable-genres-tooltip')">
@ -226,144 +255,9 @@
</div>
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('blacklist'); as formControl) {
<app-setting-item [title]="t('blacklist-label')" [subtitle]="t('blacklist-tooltip')">
<ng-template #view>
@let val = breakTags(formControl.value);
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>s
<ng-template #edit>
<textarea rows="3" id="blacklist" class="form-control" formControlName="blacklist"></textarea>
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('whitelist'); as formControl) {
<app-setting-item [title]="t('whitelist-label')" [subtitle]="t('whitelist-tooltip')">
<ng-template #view>
@let val = breakTags(formControl.value);
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>s
<ng-template #edit>
<textarea rows="3" id="whitelist" class="form-control" formControlName="whitelist"></textarea>
</ng-template>
</app-setting-item>
}
</div>
<div class="setting-section-break"></div>
<h4>{{t('age-rating-mapping-title')}}</h4>
<p>{{t('age-rating-mapping-description')}}</p>
<div formArrayName="ageRatingMappings">
@for(mapping of ageRatingMappings.controls; track mapping; let i = $index) {
<div [formGroupName]="i" class="row mb-2">
<div class="col-md-4 d-flex align-items-center justify-content-center">
<input type="text" class="form-control" formControlName="str" autocomplete="off" />
</div>
<div class="col-md-2 d-flex align-items-center justify-content-center">
<i class="fa fa-arrow-right" aria-hidden="true"></i>
</div>
<div class="col-md-4 d-flex align-items-center justify-content-center">
<select class="form-select" formControlName="rating">
@for (ageRating of ageRatings; track ageRating.value) {
<option [value]="ageRating.value">
{{ageRating.value | ageRating}}
</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-icon" (click)="removeAgeRatingMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
@if($last) {
<button class="btn btn-icon" (click)="addAgeRatingMapping()">
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
}
</div>
</div>
} @empty {
<button class="btn btn-secondary" (click)="addAgeRatingMapping()">
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-age-rating-mapping-label')}}
</button>
}
</div>
<div class="setting-section-break"></div>
<!-- Field Mapping Table -->
<h4>{{t('field-mapping-title')}}</h4>
<p>{{t('field-mapping-description')}}</p>
<div formArrayName="fieldMappings">
@for (mapping of fieldMappings.controls; track mapping; let i = $index) {
<div [formGroupName]="i" class="row mb-2">
<div class="col-md-2">
<select class="form-select" formControlName="sourceType">
<option [value]="MetadataFieldType.Genre">{{t('genre')}}</option>
<option [value]="MetadataFieldType.Tag">{{t('tag')}}</option>
</select>
</div>
<div class="col-md-2">
<input type="text" class="form-control" formControlName="sourceValue"
placeholder="Source genre/tag" />
</div>
<div class="col-md-2">
<select class="form-select" formControlName="destinationType">
<option [value]="MetadataFieldType.Genre">{{t('genre')}}</option>
<option [value]="MetadataFieldType.Tag">{{t('tag')}}</option>
</select>
</div>
<div class="col-md-2">
<input type="text" class="form-control" formControlName="destinationValue"
placeholder="Destination genre/tag" />
</div>
<div class="col-md-2">
<div class="form-check">
<input id="remove-source-tag-{{i}}" type="checkbox" class="form-check-input"
formControlName="excludeFromSource">
<label [for]="'remove-source-tag-' + i" class="form-check-label">
{{t('remove-source-tag-label')}}
</label>
</div>
</div>
<div class="col-md-2">
<button class="btn btn-icon" (click)="removeFieldMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
@if ($last) {
<button class="btn btn-icon" (click)="addFieldMapping()">
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
}
</div>
</div>
} @empty {
<button class="btn btn-secondary" (click)="addFieldMapping()">
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-field-mapping-label')}}
</button>
}
</div>
@if (settings) {
<app-manage-metadata-mappings [settings]="settings" [settingsForm]="settingsForm" />
}
<div class="setting-section-break"></div>

View File

@ -1,23 +1,31 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
inject,
OnInit,
ViewChild
} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
import {SettingsService} from "../settings.service";
import {debounceTime, switchMap} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {filter, map} from "rxjs/operators";
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
import {AgeRating} from "../../_models/metadata/age-rating";
import {MetadataService} from "../../_services/metadata.service";
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-settings";
import {map} from "rxjs/operators";
import {MetadataSettings} from "../_models/metadata-settings";
import {PersonRole} from "../../_models/metadata/person";
import {PersonRolePipe} from "../../_pipes/person-role.pipe";
import {allMetadataSettingField, MetadataSettingField} from "../_models/metadata-setting-field";
import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe";
import {
ManageMetadataMappingsComponent,
MetadataMappingsExport
} from "../manage-metadata-mappings/manage-metadata-mappings.component";
import {AgeRating} from "../../_models/metadata/age-rating";
import {RouterLink} from "@angular/router";
import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component";
@Component({
@ -26,12 +34,10 @@ import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe
TranslocoDirective,
ReactiveFormsModule,
SettingSwitchComponent,
SettingItemComponent,
DefaultValuePipe,
TagBadgeComponent,
AgeRatingPipe,
PersonRolePipe,
MetadataSettingFiledPipe,
ManageMetadataMappingsComponent,
RouterLink,
],
templateUrl: './manage-metadata-settings.component.html',
@ -40,34 +46,26 @@ import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe
})
export class ManageMetadataSettingsComponent implements OnInit {
protected readonly MetadataFieldType = MetadataFieldType;
@ViewChild(ManageMetadataMappingsComponent) manageMetadataMappingsComponent!: ManageMetadataMappingsComponent;
private readonly settingService = inject(SettingsService);
private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly fb = inject(FormBuilder);
settingsForm: FormGroup = new FormGroup({});
ageRatings: Array<AgeRatingDto> = [];
ageRatingMappings = this.fb.array([]);
fieldMappings = this.fb.array([]);
settings: MetadataSettings | undefined = undefined;
personRoles: PersonRole[] = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character];
isLoaded = false;
allMetadataSettingFields = allMetadataSettingField;
ngOnInit(): void {
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings = ratings;
this.cdRef.markForCheck();
});
this.settingsForm.addControl('ageRatingMappings', this.ageRatingMappings);
this.settingsForm.addControl('fieldMappings', this.fieldMappings);
this.settingService.getMetadataSettings().subscribe(settings => {
this.settings = settings;
this.cdRef.markForCheck();
this.settingsForm.addControl('enabled', new FormControl(settings.enabled, []));
this.settingsForm.addControl('enableExtendedMetadataProcessing', new FormControl(settings.enableExtendedMetadataProcessing, []));
this.settingsForm.addControl('enableSummary', new FormControl(settings.enableSummary, []));
this.settingsForm.addControl('enableLocalizedName', new FormControl(settings.enableLocalizedName, []));
this.settingsForm.addControl('enablePublicationStatus', new FormControl(settings.enablePublicationStatus, []));
@ -86,8 +84,6 @@ export class ManageMetadataSettingsComponent implements OnInit {
this.settingsForm.addControl('enableChapterPublisher', new FormControl(settings.enableChapterPublisher, []));
this.settingsForm.addControl('enableChapterCoverImage', new FormControl(settings.enableChapterCoverImage, []));
this.settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), []));
this.settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), []));
this.settingsForm.addControl('firstLastPeopleNaming', new FormControl((settings.firstLastPeopleNaming), []));
this.settingsForm.addControl('personRoles', this.fb.group(
Object.fromEntries(
@ -107,19 +103,6 @@ export class ManageMetadataSettingsComponent implements OnInit {
)
));
if (settings.ageRatingMappings) {
Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => {
this.addAgeRatingMapping(str, rating);
});
}
if (settings.fieldMappings) {
settings.fieldMappings.forEach(mapping => {
this.addFieldMapping(mapping);
});
}
this.settingsForm.get('enablePeople')?.valueChanges.subscribe(enabled => {
const firstLastControl = this.settingsForm.get('firstLastPeopleNaming');
if (enabled) {
@ -156,49 +139,17 @@ export class ManageMetadataSettingsComponent implements OnInit {
}
breakTags(csString: string) {
if (csString) {
return csString.split(',');
}
return [];
}
packData(withFieldMappings: boolean = true) {
const model = this.settingsForm.value;
// Convert FormArray to dictionary
const ageRatingMappings = this.ageRatingMappings.controls.reduce((acc, control) => {
// @ts-ignore
const { str, rating } = control.value;
if (str && rating) {
// @ts-ignore
acc[str] = parseInt(rating + '', 10) as AgeRating;
}
return acc;
}, {});
const exp: MetadataMappingsExport = this.manageMetadataMappingsComponent.packData()
const fieldMappings = this.fieldMappings.controls.map((control) => {
const value = control.value as MetadataFieldMapping;
return {
id: value.id,
sourceType: parseInt(value.sourceType + '', 10),
destinationType: parseInt(value.destinationType + '', 10),
sourceValue: value.sourceValue,
destinationValue: value.destinationValue,
excludeFromSource: value.excludeFromSource
}
}).filter(m => m.sourceValue.length > 0 && m.destinationValue.length > 0);
// Translate blacklist string -> Array<string>
return {
...model,
ageRatingMappings,
fieldMappings: withFieldMappings ? fieldMappings : [],
blacklist: (model.blacklist || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0),
whitelist: (model.whitelist || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0),
ageRatingMappings: exp.ageRatingMappings,
fieldMappings: withFieldMappings ? exp.fieldMappings : [],
blacklist: exp.blacklist,
whitelist: exp.whitelist,
personRoles: Object.entries(this.settingsForm.get('personRoles')!.value)
.filter(([_, value]) => value)
.map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)]),
@ -208,36 +159,6 @@ export class ManageMetadataSettingsComponent implements OnInit {
}
}
addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) {
const mappingGroup = this.fb.group({
str: [str, Validators.required],
rating: [rating, Validators.required]
});
// @ts-ignore
this.ageRatingMappings.push(mappingGroup);
}
removeAgeRatingMappingRow(index: number) {
this.ageRatingMappings.removeAt(index);
}
addFieldMapping(mapping: MetadataFieldMapping | null = null) {
const mappingGroup = this.fb.group({
id: [mapping?.id || 0],
sourceType: [mapping?.sourceType || MetadataFieldType.Genre, Validators.required],
destinationType: [mapping?.destinationType || MetadataFieldType.Genre, Validators.required],
sourceValue: [mapping?.sourceValue || '', Validators.required],
destinationValue: [mapping?.destinationValue || ''],
excludeFromSource: [mapping?.excludeFromSource || false]
});
//@ts-ignore
this.fieldMappings.push(mappingGroup);
}
removeFieldMappingRow(index: number) {
this.fieldMappings.removeAt(index);
}
protected readonly SettingsTabId = SettingsTabId;
}

View File

@ -0,0 +1,44 @@
<ng-container *transloco="let t; prefix: 'manage-metadata-settings'">
@if (licenseService.hasValidLicenseSignal()) {
<p class="alert alert-warning" role="alert">{{t('k+-warning')}}</p>
}
<form class="row g-0 mt-4 mb-4" [formGroup]="settingsForm">
<div class="col-md-6 col-sm-12">
@if (settingsForm.get('enableExtendedMetadataProcessing'); as control) {
<app-setting-switch [title]="t('enable-extended-metadata-processing-label')" [subtitle]="t('enable-extended-metadata-processing-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-extended-metadata-processing" type="checkbox" class="form-check-input" formControlName="enableExtendedMetadataProcessing">
</div>
</ng-template>
</app-setting-switch>
}
</div>
</form>
<div class="row g-0 mt-4 mb-4">
<div class="col-md-6 col-sm-12">
<button class="btn btn-secondary" (click)="manageMetadataMappingsComponent.export()">
{{ t('export-settings') }}
</button>
<div class="text-muted mt-2">{{t('export-tooltip')}}</div>
</div>
<div class="col-md-6 col-sm-12">
<button class="btn btn-secondary" routerLink="/settings" [fragment]="SettingsTabId.MappingsImport">
{{ t('import-settings') }}
</button>
<div class="text-muted mt-2">{{t('import-tooltip')}}</div>
</div>
</div>
<div class="setting-section-break"></div>
<h4 class="mb-4">{{t('tags-header')}}</h4>
@if (settings) {
<app-manage-metadata-mappings [settingsForm]="settingsForm" [settings]="settings"></app-manage-metadata-mappings>
}
</ng-container>

View File

@ -0,0 +1,86 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
inject,
OnInit,
ViewChild
} from '@angular/core';
import {SettingsService} from "../settings.service";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {
ManageMetadataMappingsComponent,
MetadataMappingsExport
} from "../manage-metadata-mappings/manage-metadata-mappings.component";
import {MetadataSettings} from "../_models/metadata-settings";
import {debounceTime, switchMap} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {map} from "rxjs/operators";
import {TranslocoDirective} from "@jsverse/transloco";
import {LicenseService} from "../../_services/license.service";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {RouterLink} from "@angular/router";
import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component";
/**
* Metadata settings for which a K+ license is not required
*/
@Component({
selector: 'app-manage-public-metadata-settings',
imports: [
ManageMetadataMappingsComponent,
TranslocoDirective,
ReactiveFormsModule,
RouterLink,
SettingSwitchComponent,
],
templateUrl: './manage-public-metadata-settings.component.html',
styleUrl: './manage-public-metadata-settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManagePublicMetadataSettingsComponent implements OnInit {
@ViewChild(ManageMetadataMappingsComponent) manageMetadataMappingsComponent!: ManageMetadataMappingsComponent;
private readonly settingService = inject(SettingsService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
protected readonly licenseService = inject(LicenseService);
settingsForm: FormGroup = new FormGroup({});
settings: MetadataSettings | undefined = undefined;
ngOnInit(): void {
this.settingService.getMetadataSettings().subscribe(settings => {
this.settings = settings;
this.settingsForm.addControl('enableExtendedMetadataProcessing', new FormControl(this.settings.enableExtendedMetadataProcessing, []));
this.cdRef.markForCheck();
});
this.settingsForm.valueChanges.pipe(
debounceTime(300),
takeUntilDestroyed(this.destroyRef),
map(_ => this.packData()),
switchMap((data) => this.settingService.updateMetadataSettings(data)),
).subscribe();
}
packData() {
const model = Object.assign({}, this.settings);
const formValue = this.settingsForm.value;
const exp: MetadataMappingsExport = this.manageMetadataMappingsComponent.packData()
model.enableExtendedMetadataProcessing = formValue.enableExtendedMetadataProcessing;
model.ageRatingMappings = exp.ageRatingMappings;
model.fieldMappings = exp.fieldMappings;
model.whitelist = exp.whitelist;
model.blacklist = exp.blacklist;
return model;
}
protected readonly SettingsTabId = SettingsTabId;
}

View File

@ -5,6 +5,8 @@ import { environment } from 'src/environments/environment';
import { TextResonse } from '../_types/text-response';
import { ServerSettings } from './_models/server-settings';
import {MetadataSettings} from "./_models/metadata-settings";
import {MetadataMappingsExport} from "./manage-metadata-mappings/manage-metadata-mappings.component";
import {FieldMappingsImportResult, ImportSettings} from "../_models/import-field-mappings";
/**
* Used only for the Test Email Service call
@ -35,6 +37,14 @@ export class SettingsService {
return this.http.post<MetadataSettings>(this.baseUrl + 'settings/metadata-settings', model);
}
importFieldMappings(data: MetadataMappingsExport, settings: ImportSettings) {
const body = {
data: data,
settings: settings,
}
return this.http.post<FieldMappingsImportResult>(this.baseUrl + 'settings/import-field-mappings', body);
}
updateServerSettings(model: ServerSettings) {
return this.http.post<ServerSettings>(this.baseUrl + 'settings', model);
}

View File

@ -76,7 +76,7 @@ export class ImportCblComponent {
fileUploadControl = new FormControl<undefined | Array<File>>(undefined, [
FileUploadValidators.accept(['.cbl']),
FileUploadValidators.accept(['.cbl'])
]);
uploadForm = new FormGroup({

View File

@ -49,6 +49,14 @@
}
}
@defer (when fragment === SettingsTabId.ManageMetadata; prefetch on idle) {
@if (fragment === SettingsTabId.ManageMetadata) {
<div class="scale col-md-12">
<app-manage-public-metadata-settings></app-manage-public-metadata-settings>
</div>
}
}
@defer (when fragment === SettingsTabId.MediaIssues; prefetch on idle) {
@if (fragment === SettingsTabId.MediaIssues) {
<div class="scale col-md-12">
@ -209,6 +217,14 @@
}
}
@defer (when fragment === SettingsTabId.MappingsImport; prefetch on idle) {
@if (fragment === SettingsTabId.MappingsImport) {
<div class="scale col-md-12">
<app-import-mappings></app-import-mappings>
</div>
}
}
@defer (when fragment === SettingsTabId.MALStackImport; prefetch on idle) {
@if(hasActiveLicense && fragment === SettingsTabId.MALStackImport) {

View File

@ -55,6 +55,10 @@ import {
import {
ManageReadingProfilesComponent
} from "../../../user-settings/manage-reading-profiles/manage-reading-profiles.component";
import {
ManagePublicMetadataSettingsComponent
} from "../../../admin/manage-public-metadata-settings/manage-public-metadata-settings.component";
import {ImportMappingsComponent} from "../../../admin/import-mappings/import-mappings.component";
@Component({
selector: 'app-settings',
@ -91,7 +95,9 @@ import {
EmailHistoryComponent,
ScrobblingHoldsComponent,
ManageMetadataSettingsComponent,
ManageReadingProfilesComponent
ManageReadingProfilesComponent,
ManagePublicMetadataSettingsComponent,
ImportMappingsComponent
],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',

View File

@ -377,4 +377,21 @@ export class DownloadService {
return null;
}
/**
* Download the given data as a json file
* @param data
* @param title may include the json file extension
*/
downloadObjectAsJson(data: any, title: string) {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = title.endsWith('.json') ? title : title + '.json';
a.click();
URL.revokeObjectURL(url);
}
}

View File

@ -30,10 +30,12 @@ export enum SettingsTabId {
Statistics = 'admin-statistics',
MediaIssues = 'admin-media-issues',
EmailHistory = 'admin-email-history',
ManageMetadata = 'admin-public-metadata',
// Kavita+
KavitaPlusLicense = 'admin-kavitaplus',
MALStackImport = 'mal-stack-import',
MappingsImport = 'admin-mappings-import',
MatchedMetadata = 'admin-matched-metadata',
ManageUserTokens = 'admin-manage-tokens',
Metadata = 'admin-metadata',
@ -124,6 +126,7 @@ export class PreferenceNavComponent implements AfterViewInit {
title: 'server-section-title',
children: [
new SideNavItem(SettingsTabId.General, [Role.Admin]),
new SideNavItem(SettingsTabId.ManageMetadata, [Role.Admin]),
new SideNavItem(SettingsTabId.Media, [Role.Admin]),
new SideNavItem(SettingsTabId.Email, [Role.Admin]),
new SideNavItem(SettingsTabId.Users, [Role.Admin]),
@ -135,6 +138,7 @@ export class PreferenceNavComponent implements AfterViewInit {
title: 'import-section-title',
children: [
new SideNavItem(SettingsTabId.CBLImport, [], undefined, [Role.ReadOnly]),
new SideNavItem(SettingsTabId.MappingsImport, [Role.Admin]),
]
},
{

View File

@ -744,6 +744,12 @@
},
"manage-metadata-settings": {
"k+-warning": "The settings below are shared with those found on the K+ page. You may change them in either location",
"export-settings": "Export",
"export-tooltip": "Export your blacklist and whitelist, age rating, and field mappings",
"export-file-name": "Kavita Metadata Settings Export.json",
"import-settings": "Import",
"import-tooltip": "Import your or someone else's settings",
"description": "Kavita+ has the ability to download and write some limited metadata to the Database. This page allows for you to toggle what is in scope.",
"enabled-label": "Enable Metadata Download",
"enabled-tooltip": "Allow Kavita to download metadata and write to it's database.",
@ -776,25 +782,34 @@
"enable-genres-tooltip": "Allow Series Genres to be written.",
"enable-tags-label": "Tags",
"enable-tags-tooltip": "Allow Series Tags to be written.",
"enable-extended-metadata-processing-label": "Extended metadata processing",
"enable-extended-metadata-processing-tooltip": "Should genres and tags sourced from ComicInfo or added via the UI be processed using the blacklist, whitelist, age rating mappings, and field mappings.",
"blacklist-label": "Blacklist Genres/Tags",
"blacklist-tooltip": "Anything in this list will be removed from both Genre and Tag processing. This is a place to add genres/tags you <b>do not</b> want written. Ensure they are comma-separated.",
"whitelist-label": "Whitelist Tags",
"whitelist-tooltip": "Only allow a string in this list from being written for <b>Tags</b>. Ensure they are comma-separated.",
"age-rating-mapping-title": "Age Rating Mapping",
"age-rating-mapping-description": "Any strings on the left if found in either Genre or Tags will set the Age Rating on the Series.",
"age-rating-mapping-description": "Any strings on the left, if found in either Genre or Tags, will set the Age Rating on the Series. Matching is normalized and case-insensitive, the highest age rating is used.",
"genre": "Genre",
"tag": "Tag",
"remove-source-tag-label": "Remove Source Tag",
"add-field-mapping-label": "Add Field Mapping",
"add-age-rating-mapping-label": "Add Age Rating Mapping",
"remove-age-rating-mapping-label": "Remove age rating mapping",
"remove-field-mapping-label": "Remove field mapping",
"field-mapping-title": "Field Mapping",
"field-mapping-description": "Setup rules for certain strings found in Genre/Tag field and map it to a new string in Genre/Tag and optionally remove it from the Source list. Only applicable when Genre/Tag are enabled to be written.",
"field-mapping-description": "Setup rules for certain strings found in Genre/Tag field and map it to a new string in Genre/Tag and optionally remove it from the Source list. Matching is normalized and case-insensitive, the first matching value is used. Only applicable when Genre/Tag are enabled to be written.",
"first-last-name-label": "First Last Naming",
"first-last-name-tooltip": "Ensure People's names are written First then Last",
"person-roles-label": "Roles",
"overrides-label": "Overrides",
"overrides-description": "Allow Kavita to write over locked fields.",
"chapter-header": "Chapter Fields"
"chapter-header": "Chapter Fields",
"series-header": "Series Fields",
"people-header": "People",
"tags-header": "Tags",
"source-genre-tags-placeholder": "Source genre/tag",
"dest-genre-tags-placeholder": "Destination genre/tag"
},
"book-line-overlay": {
@ -1713,6 +1728,7 @@
"admin-matched-metadata": "Matched Metadata",
"admin-manage-tokens": "Manage User Tokens",
"admin-metadata": "Manage Metadata",
"admin-mappings-import": "Metadata settings",
"scrobble-holds": "Scrobble Holds",
"account": "Account",
"preferences": "Preferences",
@ -1724,7 +1740,8 @@
"theme": "Theme",
"customize": "Customize",
"cbl-import": "CBL Reading List",
"mal-stack-import": "MAL Stack"
"mal-stack-import": "MAL Stack",
"admin-public-metadata": "Manage Metadata"
},
"collection-detail": {
@ -1930,6 +1947,45 @@
"no-data": "{{user-scrobble-history.no-data}}"
},
"import-mappings": {
"import-step": "Import",
"configure-step": "Configure",
"conflicts-step": "Resolve collisions",
"finalize-step": "Finalize",
"prev": "{{import-cbl-modal.prev}}",
"import": "{{import-cbl-modal.import}}",
"next": "{{import-cbl-modal.next}}",
"save": "{{common.save}}",
"import-description": "Upload a file you, or someone else has exported to replace or merge with your current settings.",
"select-files-warning": "You must upload a json file to continue",
"invalid-file": "Failed to parse your file, check your input",
"file-no-valid-content": "Your import did not contain any meaningful data to continue with",
"fields-to-import": "Settings",
"fields-to-import-tooltip": "Disable to skip a setting fully",
"whitelist-label": "Tags whitelist",
"blacklist-label": "Tags blacklist",
"age-ratings-label": "Age rating mappings",
"field-mappings-label": "Field mappings",
"age-ratings-conflicts-tooltip": "Decide which age rating to use",
"field-mappings-conflicts-tooltip": "Decide which destination result to use",
"import-mode-label": "Import mode",
"import-mode-tooltip": "Replace disregards your current settings, merge will try and resolve conflcits in the way you've configured",
"merge": "Merge",
"replace": "Replace",
"resolution-label": "Conflict resolution",
"resolution-tooltip": "Decide how Kavita should handle conflicts",
"manual": "Manual",
"keep": "Keep",
"to-pick": "Choose resolution",
"finalize-title": "Preview of your settings, press save to finish your import"
},
"import-cbl-modal": {
"close": "{{common.close}}",
"title": "CBL Import",
@ -2423,7 +2479,11 @@
"invalid-password-reset-url": "Invalid reset password url",
"delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete",
"theme-manual-upload": "There was an issue creating Theme from manual upload",
"theme-already-in-use": "Theme already exists by that name"
"theme-already-in-use": "Theme already exists by that name",
"import-fields": {
"non-unique-age-ratings": "Age rating mapping keys aren't unique, please correct your import file",
"non-unique-fields": "Field mappings do not have a unique id, please correct your import file"
}
},
"metadata-builder": {
@ -3039,6 +3099,7 @@
"submit": "Submit",
"email": "Email",
"read": "Read",
"unknown": "Unknown",
"loading": "Loading…",
"username": "Username",
"password": "Password",