598 lines
27 KiB
C#
598 lines
27 KiB
C#
using System.Data;
|
|
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Dapper;
|
|
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
using PlotLine.ViewModels;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface INameIntelligenceService
|
|
{
|
|
Task<NameReferenceImportResult> EnsureReferenceDataImportedAsync(bool force = false, CancellationToken cancellationToken = default);
|
|
Task<NameReferenceImportResult> ValidatePackageAsync(CancellationToken cancellationToken = default);
|
|
Task<IReadOnlyList<NameLocale>> ListLocalesAsync(CancellationToken cancellationToken = default);
|
|
Task<NameRecognitionResult> RecogniseAsync(string rawName, string? localeCode = null, int? birthYear = null, CancellationToken cancellationToken = default);
|
|
Task<IReadOnlyList<NameSearchResult>> SearchAsync(NameSearchFilter filter, CancellationToken cancellationToken = default);
|
|
Task<NamesLibraryViewModel?> BuildLibraryAsync(NameSearchFilter filter, CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public sealed class NameIntelligenceService(
|
|
INameIntelligenceRepository repository,
|
|
ISqlConnectionFactory connectionFactory,
|
|
IProjectRepository projects,
|
|
IBookRepository books,
|
|
ICurrentUserService currentUser,
|
|
IWebHostEnvironment environment,
|
|
ILogger<NameIntelligenceService> logger) : INameIntelligenceService
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
|
|
|
public async Task<NameReferenceImportResult> EnsureReferenceDataImportedAsync(bool force = false, CancellationToken cancellationToken = default)
|
|
{
|
|
var package = await ReadAndValidatePackageAsync(cancellationToken);
|
|
if (package.Errors.Count > 0)
|
|
{
|
|
return package.ToResult(false, false, "Name reference package validation failed.");
|
|
}
|
|
|
|
var latest = await repository.GetLatestImportAsync(package.Manifest.PackageId, package.Manifest.Version, cancellationToken);
|
|
if (!force && latest is not null && string.Equals(latest.PackageChecksum, package.Checksum, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return package.ToResult(true, true, "Name reference package is already imported.");
|
|
}
|
|
|
|
using var connection = connectionFactory.CreateConnection();
|
|
connection.Open();
|
|
using var transaction = connection.BeginTransaction();
|
|
var importId = 0;
|
|
try
|
|
{
|
|
importId = await repository.StartImportAsync(package.Manifest.PackageId, package.Manifest.Version, package.Checksum, connection, transaction, cancellationToken);
|
|
await repository.UpsertLocalesAsync(package.LocaleTable, connection, transaction, cancellationToken);
|
|
await repository.UpsertGivenNamesAsync(package.GivenNameTable, connection, transaction, cancellationToken);
|
|
await repository.UpsertRelationshipsAsync(package.RelationshipTable, connection, transaction, cancellationToken);
|
|
foreach (var batch in package.UsageTables)
|
|
{
|
|
await repository.UpsertUsagesAsync(batch, connection, transaction, cancellationToken);
|
|
}
|
|
|
|
await repository.CompleteImportAsync(importId, true, package.LocaleRows, package.GivenNameRows, package.RelationshipRows, package.UsageRows, null, connection, transaction, cancellationToken);
|
|
transaction.Commit();
|
|
return package.ToResult(true, false, "Name reference package imported.");
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogError(ex, "Name reference import failed for package {PackageId} {Version}.", package.Manifest.PackageId, package.Manifest.Version);
|
|
try
|
|
{
|
|
if (importId > 0)
|
|
{
|
|
await repository.CompleteImportAsync(importId, false, 0, 0, 0, 0, ex.Message, connection, transaction, cancellationToken);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
transaction.Rollback();
|
|
}
|
|
|
|
return package.ToResult(false, false, "Name reference package import failed.", [ex.Message]);
|
|
}
|
|
}
|
|
|
|
public async Task<NameReferenceImportResult> ValidatePackageAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var package = await ReadAndValidatePackageAsync(cancellationToken);
|
|
return package.ToResult(package.Errors.Count == 0, false, package.Errors.Count == 0 ? "Name reference package validation succeeded." : "Name reference package validation failed.");
|
|
}
|
|
|
|
public Task<IReadOnlyList<NameLocale>> ListLocalesAsync(CancellationToken cancellationToken = default)
|
|
=> repository.ListLocalesAsync(cancellationToken);
|
|
|
|
public Task<NameRecognitionResult> RecogniseAsync(string rawName, string? localeCode = null, int? birthYear = null, CancellationToken cancellationToken = default)
|
|
{
|
|
var normalised = NormaliseName(rawName);
|
|
return string.IsNullOrWhiteSpace(normalised)
|
|
? Task.FromResult(new NameRecognitionResult())
|
|
: repository.RecogniseAsync(normalised, localeCode, birthYear, cancellationToken);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<NameSearchResult>> SearchAsync(NameSearchFilter filter, CancellationToken cancellationToken = default)
|
|
{
|
|
if (currentUser.UserId is not int userId || filter.ProjectID <= 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
filter.Page = Math.Max(1, filter.Page);
|
|
filter.PageSize = Math.Clamp(filter.PageSize, 1, 100);
|
|
filter.UsedScope = string.IsNullOrWhiteSpace(filter.UsedScope) ? "Project" : filter.UsedScope.Trim();
|
|
if (!string.IsNullOrWhiteSpace(filter.GenderPreference) && !filter.GenderThreshold.HasValue)
|
|
{
|
|
filter.GenderThreshold = string.Equals(filter.GenderPreference, "Mixed", StringComparison.OrdinalIgnoreCase)
|
|
? 0.65m
|
|
: 0.5m;
|
|
}
|
|
|
|
return await repository.SearchAsync(userId, filter, cancellationToken);
|
|
}
|
|
|
|
public async Task<NamesLibraryViewModel?> BuildLibraryAsync(NameSearchFilter filter, CancellationToken cancellationToken = default)
|
|
{
|
|
if (currentUser.UserId is not int userId || filter.ProjectID <= 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var project = await projects.GetForUserAsync(filter.ProjectID, userId);
|
|
if (project is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var projectBooks = await books.ListByProjectAsync(filter.ProjectID);
|
|
var locales = await ListLocalesAsync(cancellationToken);
|
|
ApplyBookDefaults(filter, projectBooks, locales);
|
|
var rows = await SearchAsync(filter, cancellationToken);
|
|
return new NamesLibraryViewModel
|
|
{
|
|
Project = project,
|
|
Filter = filter,
|
|
Locales = locales,
|
|
Books = projectBooks,
|
|
Results = rows
|
|
};
|
|
}
|
|
|
|
private static void ApplyBookDefaults(NameSearchFilter filter, IReadOnlyList<Book> projectBooks, IReadOnlyList<NameLocale> locales)
|
|
{
|
|
if (!filter.BookID.HasValue)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var book = projectBooks.FirstOrDefault(item => item.BookID == filter.BookID.Value);
|
|
if (book is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(filter.LocaleCode)
|
|
&& !string.IsNullOrWhiteSpace(book.PrimaryLocaleCode)
|
|
&& locales.Any(locale => string.Equals(locale.LocaleCode, book.PrimaryLocaleCode, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
filter.LocaleCode = book.PrimaryLocaleCode;
|
|
}
|
|
|
|
if (!filter.BirthYear.HasValue)
|
|
{
|
|
filter.BirthYear = BookContextOptions.RepresentativeBirthYear(book.StoryEra);
|
|
}
|
|
}
|
|
|
|
public static string NormaliseName(string? value)
|
|
=> string.IsNullOrWhiteSpace(value)
|
|
? string.Empty
|
|
: value.Trim().Normalize(NormalizationForm.FormKC).ToUpperInvariant();
|
|
|
|
private async Task<ValidatedNamePackage> ReadAndValidatePackageAsync(CancellationToken cancellationToken)
|
|
{
|
|
var root = Path.Combine(environment.ContentRootPath, NameIntelligenceReferencePackage.RelativePath);
|
|
var errors = new List<string>();
|
|
var manifestPath = Path.Combine(root, "manifest.json");
|
|
if (!File.Exists(manifestPath))
|
|
{
|
|
return ValidatedNamePackage.Empty(errors.Append($"Missing manifest: {manifestPath}").ToList());
|
|
}
|
|
|
|
var manifest = JsonSerializer.Deserialize<NamePackageManifest>(await File.ReadAllTextAsync(manifestPath, cancellationToken), JsonOptions)
|
|
?? new NamePackageManifest();
|
|
if (!string.Equals(manifest.PackageId, NameIntelligenceReferencePackage.PackageId, StringComparison.Ordinal))
|
|
{
|
|
errors.Add($"Unexpected package id '{manifest.PackageId}'.");
|
|
}
|
|
|
|
if (!string.Equals(manifest.Version, NameIntelligenceReferencePackage.Version, StringComparison.Ordinal))
|
|
{
|
|
errors.Add($"Unexpected package version '{manifest.Version}'.");
|
|
}
|
|
|
|
var checksum = ComputePackageChecksum(root, manifest);
|
|
var locales = ReadCsv(Path.Combine(root, "name_locales.csv"), ["locale_code", "display_name", "iso_country_code", "evidence_basis", "default_confidence", "sort_order", "is_active"], errors);
|
|
var names = ReadCsv(Path.Combine(root, "given_names.csv"), ["given_name_key", "display_name", "normalised_name", "name_type", "is_word_ambiguous", "is_active", "notes"], errors);
|
|
var relationships = ReadCsv(Path.Combine(root, "name_relationships.csv"), ["relationship_key", "from_given_name_key", "to_given_name_key", "relationship_type", "strength", "is_bidirectional", "is_active", "curation_source", "notes"], errors);
|
|
var usages = ReadCsv(Path.Combine(root, "name_usages.csv"), ["usage_key", "given_name_key", "locale_code", "birth_year_from", "birth_year_to", "male_weight", "female_weight", "usage_weight", "confidence", "source_type", "source_reference", "is_active", "notes"], errors);
|
|
|
|
ValidateCounts(manifest, locales.Count, names.Count, relationships.Count, usages.Count, errors);
|
|
ValidatePackageRows(manifest, locales, names, relationships, usages, errors);
|
|
|
|
return new ValidatedNamePackage(
|
|
manifest,
|
|
checksum,
|
|
BuildLocaleTable(locales),
|
|
BuildGivenNameTable(names),
|
|
BuildRelationshipTable(relationships),
|
|
BuildUsageTables(usages),
|
|
locales.Count,
|
|
names.Count,
|
|
relationships.Count,
|
|
usages.Count,
|
|
errors);
|
|
}
|
|
|
|
private static void ValidateCounts(NamePackageManifest manifest, int localeRows, int nameRows, int relationshipRows, int usageRows, List<string> errors)
|
|
{
|
|
if (manifest.Files.TryGetValue("name_locales", out var locales) && locales.Rows != localeRows) errors.Add($"Locale row count mismatch: manifest {locales.Rows}, file {localeRows}.");
|
|
if (manifest.Files.TryGetValue("given_names", out var names) && names.Rows != nameRows) errors.Add($"Given name row count mismatch: manifest {names.Rows}, file {nameRows}.");
|
|
if (manifest.Files.TryGetValue("name_relationships", out var relationships) && relationships.Rows != relationshipRows) errors.Add($"Relationship row count mismatch: manifest {relationships.Rows}, file {relationshipRows}.");
|
|
if (manifest.Files.TryGetValue("name_usages", out var usages) && usages.Rows != usageRows) errors.Add($"Usage row count mismatch: manifest {usages.Rows}, file {usageRows}.");
|
|
}
|
|
|
|
private static void ValidatePackageRows(NamePackageManifest manifest, List<Dictionary<string, string>> locales, List<Dictionary<string, string>> names, List<Dictionary<string, string>> relationships, List<Dictionary<string, string>> usages, List<string> errors)
|
|
{
|
|
var localeCodes = new HashSet<string>(StringComparer.Ordinal);
|
|
var nameKeys = new HashSet<string>(StringComparer.Ordinal);
|
|
var normalisedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var relationshipKeys = new HashSet<string>(StringComparer.Ordinal);
|
|
var usageKeys = new HashSet<string>(StringComparer.Ordinal);
|
|
var usageBands = new Dictionary<string, List<(int From, int To)>>(StringComparer.Ordinal);
|
|
var nameTypes = manifest.ValueSets.GetValueOrDefault("nameType") ?? [];
|
|
var relationshipTypes = manifest.ValueSets.GetValueOrDefault("relationshipType") ?? [];
|
|
var sourceTypes = manifest.ValueSets.GetValueOrDefault("sourceType") ?? [];
|
|
|
|
foreach (var row in locales)
|
|
{
|
|
RequireUnique(row["locale_code"], localeCodes, "locale_code", errors);
|
|
ValidateDecimal(row["default_confidence"], "default_confidence", errors);
|
|
ValidateBool(row["is_active"], "locale is_active", errors);
|
|
}
|
|
|
|
foreach (var row in names)
|
|
{
|
|
RequireUnique(row["given_name_key"], nameKeys, "given_name_key", errors);
|
|
RequireUnique(row["normalised_name"], normalisedNames, "normalised_name", errors);
|
|
RequireControlled(row["name_type"], nameTypes, "name_type", errors);
|
|
ValidateBool(row["is_word_ambiguous"], "is_word_ambiguous", errors);
|
|
ValidateBool(row["is_active"], "given name is_active", errors);
|
|
}
|
|
|
|
foreach (var row in relationships)
|
|
{
|
|
RequireUnique(row["relationship_key"], relationshipKeys, "relationship_key", errors);
|
|
RequireForeign(row["from_given_name_key"], nameKeys, "relationship from_given_name_key", errors);
|
|
RequireForeign(row["to_given_name_key"], nameKeys, "relationship to_given_name_key", errors);
|
|
if (row["from_given_name_key"] == row["to_given_name_key"]) errors.Add($"Self relationship '{row["relationship_key"]}' is not allowed.");
|
|
RequireControlled(row["relationship_type"], relationshipTypes, "relationship_type", errors);
|
|
ValidateDecimal(row["strength"], "strength", errors);
|
|
ValidateBool(row["is_bidirectional"], "is_bidirectional", errors);
|
|
ValidateBool(row["is_active"], "relationship is_active", errors);
|
|
}
|
|
|
|
foreach (var row in usages)
|
|
{
|
|
RequireUnique(row["usage_key"], usageKeys, "usage_key", errors);
|
|
RequireForeign(row["given_name_key"], nameKeys, "usage given_name_key", errors);
|
|
RequireForeign(row["locale_code"], localeCodes, "usage locale_code", errors);
|
|
var from = ValidateInt(row["birth_year_from"], "birth_year_from", errors);
|
|
var to = ValidateInt(row["birth_year_to"], "birth_year_to", errors);
|
|
if (from > to) errors.Add($"Usage '{row["usage_key"]}' has invalid year range.");
|
|
var male = ValidateDecimal(row["male_weight"], "male_weight", errors);
|
|
var female = ValidateDecimal(row["female_weight"], "female_weight", errors);
|
|
ValidateDecimal(row["usage_weight"], "usage_weight", errors);
|
|
ValidateDecimal(row["confidence"], "confidence", errors);
|
|
if (Math.Abs((male + female) - 1m) > 0.0002m) errors.Add($"Usage '{row["usage_key"]}' gender weights do not total 1.");
|
|
RequireControlled(row["source_type"], sourceTypes, "source_type", errors);
|
|
ValidateBool(row["is_active"], "usage is_active", errors);
|
|
var group = row["given_name_key"] + "|" + row["locale_code"];
|
|
if (!usageBands.TryGetValue(group, out var bands))
|
|
{
|
|
bands = [];
|
|
usageBands[group] = bands;
|
|
}
|
|
bands.Add((from, to));
|
|
}
|
|
|
|
foreach (var pair in usageBands)
|
|
{
|
|
var bands = pair.Value.OrderBy(item => item.From).ToList();
|
|
if (bands.Count != 5 || bands[0].From != 1900 || bands[^1].To != 2026)
|
|
{
|
|
errors.Add($"Usage bands for {pair.Key} must contain five bands covering 1900-2026.");
|
|
continue;
|
|
}
|
|
|
|
for (var i = 1; i < bands.Count; i++)
|
|
{
|
|
if (bands[i].From != bands[i - 1].To + 1)
|
|
{
|
|
errors.Add($"Usage bands for {pair.Key} are not contiguous.");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static List<Dictionary<string, string>> ReadCsv(string path, IReadOnlyList<string> expectedHeaders, List<string> errors)
|
|
{
|
|
if (!File.Exists(path))
|
|
{
|
|
errors.Add($"Missing CSV file: {path}");
|
|
return [];
|
|
}
|
|
|
|
using var reader = new StreamReader(path, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
|
var headerLine = reader.ReadLine();
|
|
var headers = ParseCsvLine(headerLine ?? string.Empty);
|
|
if (!headers.SequenceEqual(expectedHeaders, StringComparer.Ordinal))
|
|
{
|
|
errors.Add($"Unexpected headers in {Path.GetFileName(path)}.");
|
|
return [];
|
|
}
|
|
|
|
var rows = new List<Dictionary<string, string>>();
|
|
string? line;
|
|
while ((line = reader.ReadLine()) is not null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var values = ParseCsvLine(line);
|
|
if (values.Count != headers.Count)
|
|
{
|
|
errors.Add($"Invalid CSV column count in {Path.GetFileName(path)} at row {rows.Count + 2}.");
|
|
continue;
|
|
}
|
|
|
|
rows.Add(headers.Zip(values, (key, value) => new { key, value }).ToDictionary(item => item.key, item => item.value, StringComparer.Ordinal));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
private static List<string> ParseCsvLine(string line)
|
|
{
|
|
var values = new List<string>();
|
|
var builder = new StringBuilder();
|
|
var quoted = false;
|
|
for (var i = 0; i < line.Length; i++)
|
|
{
|
|
var ch = line[i];
|
|
if (quoted)
|
|
{
|
|
if (ch == '"' && i + 1 < line.Length && line[i + 1] == '"')
|
|
{
|
|
builder.Append('"');
|
|
i++;
|
|
}
|
|
else if (ch == '"')
|
|
{
|
|
quoted = false;
|
|
}
|
|
else
|
|
{
|
|
builder.Append(ch);
|
|
}
|
|
}
|
|
else if (ch == ',')
|
|
{
|
|
values.Add(builder.ToString());
|
|
builder.Clear();
|
|
}
|
|
else if (ch == '"')
|
|
{
|
|
quoted = true;
|
|
}
|
|
else
|
|
{
|
|
builder.Append(ch);
|
|
}
|
|
}
|
|
|
|
values.Add(builder.ToString());
|
|
return values;
|
|
}
|
|
|
|
private static DataTable BuildLocaleTable(IEnumerable<Dictionary<string, string>> rows)
|
|
{
|
|
var table = new DataTable();
|
|
table.Columns.Add("LocaleCode", typeof(string));
|
|
table.Columns.Add("DisplayName", typeof(string));
|
|
table.Columns.Add("IsoCountryCode", typeof(string));
|
|
table.Columns.Add("EvidenceBasis", typeof(string));
|
|
table.Columns.Add("DefaultConfidence", typeof(decimal));
|
|
table.Columns.Add("SortOrder", typeof(int));
|
|
table.Columns.Add("IsActive", typeof(bool));
|
|
foreach (var row in rows)
|
|
{
|
|
table.Rows.Add(row["locale_code"], row["display_name"], row["iso_country_code"], row["evidence_basis"], Decimal(row["default_confidence"]), Int(row["sort_order"]), Bool(row["is_active"]));
|
|
}
|
|
return table;
|
|
}
|
|
|
|
private static DataTable BuildGivenNameTable(IEnumerable<Dictionary<string, string>> rows)
|
|
{
|
|
var table = new DataTable();
|
|
table.Columns.Add("GivenNameKey", typeof(string));
|
|
table.Columns.Add("DisplayName", typeof(string));
|
|
table.Columns.Add("NormalisedName", typeof(string));
|
|
table.Columns.Add("NameType", typeof(string));
|
|
table.Columns.Add("IsWordAmbiguous", typeof(bool));
|
|
table.Columns.Add("IsActive", typeof(bool));
|
|
table.Columns.Add("Notes", typeof(string));
|
|
foreach (var row in rows)
|
|
{
|
|
table.Rows.Add(row["given_name_key"], row["display_name"], row["normalised_name"], row["name_type"], Bool(row["is_word_ambiguous"]), Bool(row["is_active"]), DbNull(row["notes"]));
|
|
}
|
|
return table;
|
|
}
|
|
|
|
private static DataTable BuildRelationshipTable(IEnumerable<Dictionary<string, string>> rows)
|
|
{
|
|
var table = new DataTable();
|
|
table.Columns.Add("RelationshipKey", typeof(string));
|
|
table.Columns.Add("FromGivenNameKey", typeof(string));
|
|
table.Columns.Add("ToGivenNameKey", typeof(string));
|
|
table.Columns.Add("RelationshipType", typeof(string));
|
|
table.Columns.Add("Strength", typeof(decimal));
|
|
table.Columns.Add("IsBidirectional", typeof(bool));
|
|
table.Columns.Add("IsActive", typeof(bool));
|
|
table.Columns.Add("CurationSource", typeof(string));
|
|
table.Columns.Add("Notes", typeof(string));
|
|
foreach (var row in rows)
|
|
{
|
|
table.Rows.Add(row["relationship_key"], row["from_given_name_key"], row["to_given_name_key"], row["relationship_type"], Decimal(row["strength"]), Bool(row["is_bidirectional"]), Bool(row["is_active"]), DbNull(row["curation_source"]), DbNull(row["notes"]));
|
|
}
|
|
return table;
|
|
}
|
|
|
|
private static IReadOnlyList<DataTable> BuildUsageTables(IEnumerable<Dictionary<string, string>> rows)
|
|
{
|
|
var tables = new List<DataTable>();
|
|
var table = NewUsageTable();
|
|
foreach (var row in rows)
|
|
{
|
|
if (table.Rows.Count >= 5000)
|
|
{
|
|
tables.Add(table);
|
|
table = NewUsageTable();
|
|
}
|
|
|
|
table.Rows.Add(row["usage_key"], row["given_name_key"], row["locale_code"], Int(row["birth_year_from"]), Int(row["birth_year_to"]), Decimal(row["male_weight"]), Decimal(row["female_weight"]), Decimal(row["usage_weight"]), Decimal(row["confidence"]), row["source_type"], DbNull(row["source_reference"]), Bool(row["is_active"]), DbNull(row["notes"]));
|
|
}
|
|
|
|
if (table.Rows.Count > 0)
|
|
{
|
|
tables.Add(table);
|
|
}
|
|
|
|
return tables;
|
|
}
|
|
|
|
private static DataTable NewUsageTable()
|
|
{
|
|
var table = new DataTable();
|
|
table.Columns.Add("UsageKey", typeof(string));
|
|
table.Columns.Add("GivenNameKey", typeof(string));
|
|
table.Columns.Add("LocaleCode", typeof(string));
|
|
table.Columns.Add("BirthYearFrom", typeof(int));
|
|
table.Columns.Add("BirthYearTo", typeof(int));
|
|
table.Columns.Add("MaleWeight", typeof(decimal));
|
|
table.Columns.Add("FemaleWeight", typeof(decimal));
|
|
table.Columns.Add("UsageWeight", typeof(decimal));
|
|
table.Columns.Add("Confidence", typeof(decimal));
|
|
table.Columns.Add("SourceType", typeof(string));
|
|
table.Columns.Add("SourceReference", typeof(string));
|
|
table.Columns.Add("IsActive", typeof(bool));
|
|
table.Columns.Add("Notes", typeof(string));
|
|
return table;
|
|
}
|
|
|
|
private static string ComputePackageChecksum(string root, NamePackageManifest manifest)
|
|
{
|
|
using var hash = SHA256.Create();
|
|
foreach (var fileName in manifest.ImportOrder)
|
|
{
|
|
var bytes = File.ReadAllBytes(Path.Combine(root, fileName));
|
|
hash.TransformBlock(bytes, 0, bytes.Length, null, 0);
|
|
}
|
|
hash.TransformFinalBlock([], 0, 0);
|
|
return Convert.ToHexString(hash.Hash ?? []);
|
|
}
|
|
|
|
private static void RequireUnique(string value, HashSet<string> values, string label, List<string> errors)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value) || !values.Add(value))
|
|
{
|
|
errors.Add($"Duplicate or empty {label}: '{value}'.");
|
|
}
|
|
}
|
|
|
|
private static void RequireForeign(string value, HashSet<string> values, string label, List<string> errors)
|
|
{
|
|
if (!values.Contains(value))
|
|
{
|
|
errors.Add($"Unknown {label}: '{value}'.");
|
|
}
|
|
}
|
|
|
|
private static void RequireControlled(string value, IReadOnlyCollection<string> allowed, string label, List<string> errors)
|
|
{
|
|
if (!allowed.Contains(value, StringComparer.Ordinal))
|
|
{
|
|
errors.Add($"Invalid {label}: '{value}'.");
|
|
}
|
|
}
|
|
|
|
private static int ValidateInt(string value, string label, List<string> errors)
|
|
{
|
|
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
|
|
{
|
|
return result;
|
|
}
|
|
errors.Add($"Invalid integer {label}: '{value}'.");
|
|
return 0;
|
|
}
|
|
|
|
private static decimal ValidateDecimal(string value, string label, List<string> errors)
|
|
{
|
|
if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var result) && result >= 0 && result <= 1)
|
|
{
|
|
return result;
|
|
}
|
|
errors.Add($"Invalid decimal {label}: '{value}'.");
|
|
return 0;
|
|
}
|
|
|
|
private static void ValidateBool(string value, string label, List<string> errors)
|
|
{
|
|
if (!bool.TryParse(value, out _))
|
|
{
|
|
errors.Add($"Invalid boolean {label}: '{value}'.");
|
|
}
|
|
}
|
|
|
|
private static int Int(string value) => int.Parse(value, CultureInfo.InvariantCulture);
|
|
private static decimal Decimal(string value) => decimal.Parse(value, CultureInfo.InvariantCulture);
|
|
private static bool Bool(string value) => bool.Parse(value);
|
|
private static object DbNull(string value) => string.IsNullOrWhiteSpace(value) ? DBNull.Value : value;
|
|
|
|
private sealed record ValidatedNamePackage(
|
|
NamePackageManifest Manifest,
|
|
string Checksum,
|
|
DataTable LocaleTable,
|
|
DataTable GivenNameTable,
|
|
DataTable RelationshipTable,
|
|
IReadOnlyList<DataTable> UsageTables,
|
|
int LocaleRows,
|
|
int GivenNameRows,
|
|
int RelationshipRows,
|
|
int UsageRows,
|
|
IReadOnlyList<string> Errors)
|
|
{
|
|
public static ValidatedNamePackage Empty(IReadOnlyList<string> errors) => new(new(), string.Empty, new(), new(), new(), [], 0, 0, 0, 0, errors);
|
|
|
|
public NameReferenceImportResult ToResult(bool succeeded, bool alreadyCurrent, string message, IReadOnlyList<string>? extraErrors = null)
|
|
=> new(succeeded, alreadyCurrent, message, Manifest.PackageId, Manifest.Version, LocaleRows, GivenNameRows, RelationshipRows, UsageRows, Errors.Concat(extraErrors ?? []).ToList());
|
|
}
|
|
|
|
private sealed class NamePackageManifest
|
|
{
|
|
public string PackageId { get; set; } = string.Empty;
|
|
public string Version { get; set; } = string.Empty;
|
|
public Dictionary<string, NamePackageFile> Files { get; set; } = [];
|
|
public Dictionary<string, string[]> ValueSets { get; set; } = [];
|
|
public string[] ImportOrder { get; set; } = [];
|
|
}
|
|
|
|
private sealed class NamePackageFile
|
|
{
|
|
public string File { get; set; } = string.Empty;
|
|
public int Rows { get; set; }
|
|
}
|
|
}
|