mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-08-07 09:01:25 -04:00
Genre, Tags mappings Import & Export (#3959)
Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
This commit is contained in:
parent
22058f7413
commit
0770bd344e
@ -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
|
||||
|
@ -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
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
86
API/DTOs/ImportFieldMappings.cs
Normal file
86
API/DTOs/ImportFieldMappings.cs
Normal 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; }
|
||||
}
|
@ -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; }
|
||||
}
|
||||
|
15
API/DTOs/Settings/ImportFieldMappingsDto.cs
Normal file
15
API/DTOs/Settings/ImportFieldMappingsDto.cs
Normal 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; }
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
3727
API/Data/Migrations/20250727185204_AddEnableExtendedMetadataProcessing.Designer.cs
generated
Normal file
3727
API/Data/Migrations/20250727185204_AddEnableExtendedMetadataProcessing.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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");
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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--;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -13,7 +13,8 @@
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
"style": "scss",
|
||||
"changeDetection": "OnPush"
|
||||
},
|
||||
"@schematics/angular:application": {
|
||||
"strict": true
|
||||
|
15
UI/Web/package-lock.json
generated
15
UI/Web/package-lock.json
generated
@ -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"
|
||||
|
32
UI/Web/src/app/_models/import-field-mappings.ts
Normal file
32
UI/Web/src/app/_models/import-field-mappings.ts
Normal 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[];
|
||||
}
|
26
UI/Web/src/app/_pipes/conflict-resolution.pipe.ts
Normal file
26
UI/Web/src/app/_pipes/conflict-resolution.pipe.ts
Normal 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');
|
||||
}
|
||||
|
||||
}
|
25
UI/Web/src/app/_pipes/import-mode.pipe.ts
Normal file
25
UI/Web/src/app/_pipes/import-mode.pipe.ts
Normal 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');
|
||||
}
|
||||
|
||||
}
|
25
UI/Web/src/app/_pipes/metadata-field-type.pipe.ts
Normal file
25
UI/Web/src/app/_pipes/metadata-field-type.pipe.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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});
|
||||
|
||||
|
||||
/**
|
||||
|
@ -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>;
|
||||
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
@ -0,0 +1,3 @@
|
||||
.text-muted {
|
||||
font-size: 0.875rem;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -76,7 +76,7 @@ export class ImportCblComponent {
|
||||
|
||||
|
||||
fileUploadControl = new FormControl<undefined | Array<File>>(undefined, [
|
||||
FileUploadValidators.accept(['.cbl']),
|
||||
FileUploadValidators.accept(['.cbl'])
|
||||
]);
|
||||
|
||||
uploadForm = new FormGroup({
|
||||
|
@ -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) {
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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]),
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user