From 78f1b0e01bbfe87024e4c3fb0c7784a9122d19aa Mon Sep 17 00:00:00 2001 From: Bernd Dongus Date: Thu, 4 Apr 2024 21:24:36 +0200 Subject: [PATCH] Switch from DataConverter to AsyncDataImporter --- .../ImportResoucesTests.cs | 167 ++++++++++++------ idee5.Globalization.Test/WrongFileLink.resx | 124 +++++++++++++ .../idee5.Globalization.Test.csproj | 32 +++- .../idee5.Globalization.Web.csproj | 2 +- .../idee5.Globalization.WebApi.csproj | 2 +- .../Commands/CreateOrUpdateResourceCommand.cs | 26 +++ .../CreateOrUpdateResourceCommandHandler.cs | 39 ++++ idee5.Globalization/Log.cs | 7 + .../Queries/ResxFileInputHandler.cs | 47 ++--- .../Queries/ResxFileInputHandlerQuery.cs | 2 +- .../idee5.Globalization.csproj | 4 +- 11 files changed, 368 insertions(+), 84 deletions(-) create mode 100644 idee5.Globalization.Test/WrongFileLink.resx create mode 100644 idee5.Globalization/Commands/CreateOrUpdateResourceCommand.cs create mode 100644 idee5.Globalization/Commands/CreateOrUpdateResourceCommandHandler.cs create mode 100644 idee5.Globalization/Log.cs diff --git a/idee5.Globalization.Test/ImportResoucesTests.cs b/idee5.Globalization.Test/ImportResoucesTests.cs index 4aa83d3..fac653c 100644 --- a/idee5.Globalization.Test/ImportResoucesTests.cs +++ b/idee5.Globalization.Test/ImportResoucesTests.cs @@ -1,69 +1,128 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Linq; -using System.Threading; +using System.Text; using System.Threading.Tasks; + using idee5.Common.Data; using idee5.Globalization.Commands; using idee5.Globalization.Models; using idee5.Globalization.Queries; + using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace idee5.Globalization.Test { - [TestClass] - public class ImportResoucesTests : WithSQLiteBase { - [TestMethod] - public async Task CanImportResources() { - // Arrange - var resourcesToImport = new Resource[] { - // update existing resource (value changed) - new() { Id = "Maybe", ResourceSet = Constants.CommonTerms, BinFile = null, Textfile = null, Comment = null, Customer = "idee5", Industry = "IT", Language = "de-CH", Value = "Villücht (Branche + Kunde)" }, - // insert simple resource - new() { Id = "xyz", ResourceSet = Constants.CommonTerms, BinFile = null, Textfile = null, Comment = null, Customer = "", Industry = "", Language = "de-CH", Value = "xyz" }, - // insert textfile - new() { Id = "textfile", ResourceSet = Constants.CommonTerms, BinFile = null, Textfile = null, Comment = null, Customer = "", Industry = "", Language = "", Value = "app.config" }, - // insert binfile - new() { Id = "binfile", ResourceSet = Constants.CommonTerms, BinFile = null, Textfile = null, Comment = null, Customer = "", Industry = "", Language = "", Value = "New.png" }, - // same as existing resource - new() { Id = "Maybe", ResourceSet = Constants.CommonTerms, BinFile = null, Textfile = null, Comment = null, Customer = "", Industry = "", Language = "", Value = "Maybee" } - }; - var cmd = new ImportResourcesCommand([]) { - Resources = resourcesToImport - }; - var handler = new ImportResourcesCommandHandler(resourceUnitOfWork); - var startCount = await context.Resources.CountAsync().ConfigureAwait(false); - // Act - await handler.HandleAsync(cmd).ConfigureAwait(false); - var endCount = await context.Resources.CountAsync().ConfigureAwait(false); - // Assert - Assert.AreEqual(3, endCount - startCount); - Resource? maybeCH = await resourceUnitOfWork.ResourceRepository.GetSingleAsync(r => r.Id == "Maybe" && r.ResourceSet == Constants.CommonTerms && r.Customer == "idee5" && r.Language == "de-CH" && r.Industry == "IT").ConfigureAwait(false); - Assert.AreEqual("Villücht (Branche + Kunde)", maybeCH?.Value); - } +namespace idee5.Globalization.Test; +[TestClass] +public class ImportResoucesTests : WithSQLiteBase { + [TestMethod] + public async Task CanImportResources() { + // Arrange + var resourcesToImport = new Resource[] { + // update existing resource (value changed) + new() { Id = "Maybe", ResourceSet = Constants.CommonTerms, BinFile = null, Textfile = null, Comment = null, Customer = "idee5", Industry = "IT", Language = "de-CH", Value = "Villücht (Branche + Kunde)" }, + // insert simple resource + new() { Id = "xyz", ResourceSet = Constants.CommonTerms, BinFile = null, Textfile = null, Comment = null, Customer = "", Industry = "", Language = "de-CH", Value = "xyz" }, + // insert textfile + new() { Id = "textfile", ResourceSet = Constants.CommonTerms, BinFile = null, Textfile = null, Comment = null, Customer = "", Industry = "", Language = "", Value = "app.config" }, + // insert binfile + new() { Id = "binfile", ResourceSet = Constants.CommonTerms, BinFile = null, Textfile = null, Comment = null, Customer = "", Industry = "", Language = "", Value = "New.png" }, + // same as existing resource + new() { Id = "Maybe", ResourceSet = Constants.CommonTerms, BinFile = null, Textfile = null, Comment = null, Customer = "", Industry = "", Language = "", Value = "Maybee" } + }; + var cmd = new ImportResourcesCommand([]) { + Resources = resourcesToImport + }; + var handler = new ImportResourcesCommandHandler(resourceUnitOfWork); + var startCount = await context.Resources.CountAsync().ConfigureAwait(false); + // Act + await handler.HandleAsync(cmd).ConfigureAwait(false); + var endCount = await context.Resources.CountAsync().ConfigureAwait(false); + // Assert + Assert.AreEqual(3, endCount - startCount); + Resource? maybeCH = await resourceUnitOfWork.ResourceRepository.GetSingleAsync(r => r.Id == "Maybe" && r.ResourceSet == Constants.CommonTerms && r.Customer == "idee5" && r.Language == "de-CH" && r.Industry == "IT").ConfigureAwait(false); + Assert.AreEqual("Villücht (Branche + Kunde)", maybeCH?.Value); + } + + [TestMethod] + public async Task CanImportResxFile() { + // Arrange + var handler = new CreateOrUpdateResourceCommandHandler(resourceUnitOfWork); + var recursiveAnnotationsValidator = new RecursiveAnnotationsValidator(); + var validationReporter = new ConsoleValidationReporter(); + var inputHandler = new ResxFileInputHandler(new NullLogger()); + var outputHandler = new DataAnnotationValidationCommandHandlerAsync(recursiveAnnotationsValidator, validationReporter, handler); + + var importer = new AsyncDataImporter(inputHandler, outputHandler, new NoCleanupCommandHandler()); + var query = new ResxFileInputHandlerQuery("", "", null, null, null) { + Path = "ImportTest.resx", + ResourceSet = "testset", + TargetLanguage = "de" + }; + + // This is needed to support the text file encoding in our resx + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + // Act + await importer.ExecuteAsync(query, new NoCleanupCommand()).ConfigureAwait(false); + + // Assert + List res = await resourceUnitOfWork.ResourceRepository.GetAsync(r => r.ResourceSet == "testset").ConfigureAwait(false); + Assert.AreEqual(5, res.Count); + Assert.IsNotNull(res.SingleOrDefault(r => r.Id == "CommentedText")?.Comment); + Assert.IsNotNull(res.SingleOrDefault(r => r.Id == "Icon1")?.BinFile); + Assert.IsNotNull(res.SingleOrDefault(r => r.Id == "NewTextFile")?.Textfile); + } + [TestMethod] + public async Task DoesNotThrowIfResxNotFound() { + // Arrange + var handler = new CreateOrUpdateResourceCommandHandler(resourceUnitOfWork); + var recursiveAnnotationsValidator = new RecursiveAnnotationsValidator(); + var validationReporter = new ConsoleValidationReporter(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(o => o.AddConsole()); + var logger = loggerFactory.CreateLogger(); + var inputHandler = new ResxFileInputHandler(logger); + var outputHandler = new DataAnnotationValidationCommandHandlerAsync(recursiveAnnotationsValidator, validationReporter, handler); + + var importer = new AsyncDataImporter(inputHandler, outputHandler, new NoCleanupCommandHandler()); + var query = new ResxFileInputHandlerQuery("", "", null, null, null) { + Path = "DoesNotExist.resx", + ResourceSet = "testset", + TargetLanguage = "de" + }; + + // Act + await importer.ExecuteAsync(query, new NoCleanupCommand()).ConfigureAwait(false); + + // Assert + int count = await resourceUnitOfWork.ResourceRepository.CountAsync(r => r.ResourceSet == "testset").ConfigureAwait(false); + Assert.AreEqual(0, count); + } + [TestMethod] + public async Task DoesNoThrowOnUnavailableFileLink() { + // Arrange + var handler = new CreateOrUpdateResourceCommandHandler(resourceUnitOfWork); + var recursiveAnnotationsValidator = new RecursiveAnnotationsValidator(); + var validationReporter = new ConsoleValidationReporter(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(o => o.AddConsole()); + var logger = loggerFactory.CreateLogger(); + var inputHandler = new ResxFileInputHandler(logger); + var outputHandler = new DataAnnotationValidationCommandHandlerAsync(recursiveAnnotationsValidator, validationReporter, handler); - [TestMethod] - public async Task CanImportResxFileAsync() { - // Arrange - var handler = new ImportResourcesCommandHandler(resourceUnitOfWork); - var recursiveAnnotationsValidator = new RecursiveAnnotationsValidator(); - var validationReporter = new ConsoleValidationReporter(); - var inputHandler = new ResxFileInputHandler(); - var outputHandler = new DataAnnotationValidationCommandHandlerAsync(recursiveAnnotationsValidator, validationReporter, handler); - var converter = new DataConverterAsync(inputHandler, outputHandler); - - // TODO: var converter = new AsyncDataImporter(inputHandler, outputHandler,new NoCleanupCommandHandler()); - var query = new ResxFileInputHandlerQuery("", "", null, null, null) { - Path = "ImportTes.resx", - ResourceSet = "testset", - TargetLanguage = "de" - }; + var importer = new AsyncDataImporter(inputHandler, outputHandler, new NoCleanupCommandHandler()); + var query = new ResxFileInputHandlerQuery("", "", null, null, null) { + Path = "WrongFileLink.resx", + ResourceSet = "testset", + TargetLanguage = "de" + }; - // Act - await converter.ExecuteAsync(query, CancellationToken.None).ConfigureAwait(false); + // Act + await importer.ExecuteAsync(query, new NoCleanupCommand()).ConfigureAwait(false); - // Assert - var count = await resourceUnitOfWork.ResourceRepository.CountAsync(r => r.ResourceSet == "testset").ConfigureAwait(false); - Assert.AreNotEqual(5, count); - } + // Assert + int count = await resourceUnitOfWork.ResourceRepository.CountAsync(r => r.ResourceSet == "testset").ConfigureAwait(false); + Assert.AreEqual(1, count); } } diff --git a/idee5.Globalization.Test/WrongFileLink.resx b/idee5.Globalization.Test/WrongFileLink.resx new file mode 100644 index 0000000..2b0ee28 --- /dev/null +++ b/idee5.Globalization.Test/WrongFileLink.resx @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + DoesNotExist.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + \ No newline at end of file diff --git a/idee5.Globalization.Test/idee5.Globalization.Test.csproj b/idee5.Globalization.Test/idee5.Globalization.Test.csproj index 1c226ae..ae48c69 100644 --- a/idee5.Globalization.Test/idee5.Globalization.Test.csproj +++ b/idee5.Globalization.Test/idee5.Globalization.Test.csproj @@ -14,19 +14,47 @@ 1.0.0-beta-68 enable + + + + + + + PreserveNewest + Designer + + + PreserveNewest + + + + + - - + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + diff --git a/idee5.Globalization.Web/idee5.Globalization.Web.csproj b/idee5.Globalization.Web/idee5.Globalization.Web.csproj index f09ed6b..bc3d106 100644 --- a/idee5.Globalization.Web/idee5.Globalization.Web.csproj +++ b/idee5.Globalization.Web/idee5.Globalization.Web.csproj @@ -18,7 +18,7 @@ - + diff --git a/idee5.Globalization.WebApi/idee5.Globalization.WebApi.csproj b/idee5.Globalization.WebApi/idee5.Globalization.WebApi.csproj index 50dc912..3d6a03a 100644 --- a/idee5.Globalization.WebApi/idee5.Globalization.WebApi.csproj +++ b/idee5.Globalization.WebApi/idee5.Globalization.WebApi.csproj @@ -39,7 +39,7 @@ - + diff --git a/idee5.Globalization/Commands/CreateOrUpdateResourceCommand.cs b/idee5.Globalization/Commands/CreateOrUpdateResourceCommand.cs new file mode 100644 index 0000000..ac93bac --- /dev/null +++ b/idee5.Globalization/Commands/CreateOrUpdateResourceCommand.cs @@ -0,0 +1,26 @@ +using idee5.Globalization.Models; + +using System.ComponentModel.DataAnnotations; + +namespace idee5.Globalization.Commands; +/// +/// The create or update resource command +/// +public record CreateOrUpdateResourceCommand : ResourceKey { + /// + /// Language id according to BCP 47 http://tools.ietf.org/html/bcp47 + /// + [Required(AllowEmptyStrings = true)] + public string? Language { get; set; } + + /// + /// Value of the resource for the specified culture and parlance + /// + [Required, StringLength(maximumLength: 255, MinimumLength = 1)] + public string Value { get; set; } = ""; + + /// + /// Additional information. Mostly used to create semantic context to simplify translations + /// + public string? Comment { get; set; } +} diff --git a/idee5.Globalization/Commands/CreateOrUpdateResourceCommandHandler.cs b/idee5.Globalization/Commands/CreateOrUpdateResourceCommandHandler.cs new file mode 100644 index 0000000..beae13a --- /dev/null +++ b/idee5.Globalization/Commands/CreateOrUpdateResourceCommandHandler.cs @@ -0,0 +1,39 @@ +using idee5.Common; +using idee5.Globalization.Models; +using idee5.Globalization.Repositories; + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace idee5.Globalization.Commands; + +/// +/// The create or update resource command handler. +/// +public class CreateOrUpdateResourceCommandHandler : ICommandHandlerAsync { + private readonly IResourceUnitOfWork _resourceUnitOfWork; + + public CreateOrUpdateResourceCommandHandler(IResourceUnitOfWork resourceUnitOfWork) { + _resourceUnitOfWork = resourceUnitOfWork; + } + + /// + public async Task HandleAsync(CreateOrUpdateResourceCommand command, CancellationToken cancellationToken = default) { + if (command == null) + throw new ArgumentNullException(nameof(command)); + Resource res = new() { + BinFile = null, + Comment = command.Comment, + Customer = command.Customer, + Id = command.Id, + Industry = command.Industry, + Language = command.Language, + ResourceSet = command.ResourceSet, + Textfile = null, + Value = command.Value + }; + await _resourceUnitOfWork.ResourceRepository.UpdateOrAddAsync(res, cancellationToken).ConfigureAwait(false); + await _resourceUnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/idee5.Globalization/Log.cs b/idee5.Globalization/Log.cs new file mode 100644 index 0000000..c63518f --- /dev/null +++ b/idee5.Globalization/Log.cs @@ -0,0 +1,7 @@ +using Microsoft.Extensions.Logging; + +namespace idee5.Globalization; +internal static partial class Log { + [LoggerMessage(1, LogLevel.Warning, "Resx file '{FilePath}' not found!")] + public static partial void ResxFileNotFound(this ILogger logger, string FilePath); +} diff --git a/idee5.Globalization/Queries/ResxFileInputHandler.cs b/idee5.Globalization/Queries/ResxFileInputHandler.cs index 2bb913b..52bcfd9 100644 --- a/idee5.Globalization/Queries/ResxFileInputHandler.cs +++ b/idee5.Globalization/Queries/ResxFileInputHandler.cs @@ -1,60 +1,61 @@ using idee5.Common; +using idee5.Common.Data; using idee5.Globalization.Commands; -using idee5.Globalization.Models; + +using Microsoft.Extensions.Logging; + using System; using System.Collections; using System.Collections.Generic; using System.ComponentModel.Design; using System.IO; using System.Resources.NetStandard; +using System.Runtime.CompilerServices; using System.Threading; -using System.Threading.Tasks; namespace idee5.Globalization.Queries; -public class ResxFileInputHandler : IQueryHandlerAsync { - public async Task HandleAsync(ResxFileInputHandlerQuery query, CancellationToken cancellationToken = default) { +/// +/// The RESX file input handler +/// +public class ResxFileInputHandler : IAsyncInputHandler { + private readonly ILogger _logger; + + public ResxFileInputHandler(ILogger logger) { + _logger = logger; + } + /// + public async IAsyncEnumerable HandleAsync(ResxFileInputHandlerQuery query, [EnumeratorCancellation] CancellationToken cancellationToken = default) { if (query == null) throw new ArgumentNullException(nameof(query)); - var resultList = new List(); - string fileDir = Path.GetDirectoryName(query.Path) + Path.DirectorySeparatorChar; var fi = new FileInfo(query.Path); if (fi.Exists) { string fileContent; using (StreamReader sr = fi.OpenText()) fileContent = await sr.ReadToEndAsync().ConfigureAwait(false); using (var reader = ResXResourceReader.FromFileContents(fileContent)) { + reader.UseResXDataNodes = true; foreach (DictionaryEntry item in reader) { cancellationToken.ThrowIfCancellationRequested(); var node = (ResXDataNode)item.Value; string name = node.Name; string comment = node.Comment; - string value = node.GetValue(null as ITypeResolutionService).ToString(); - string type = node.GetValueTypeName(null as ITypeResolutionService); - - if (string.IsNullOrEmpty(type)) { - // File based resources are formatted: filename;full type name - string[] tokens = value.Split(';'); - if (tokens.Length > 0) { - string resFileName = Path.Combine(fileDir, tokens[0]); - if (File.Exists(resFileName)) - value = resFileName; - } - } - var resource = new Resource { - BinFile = null, + string value = node.FileRef?.FileName.HasValue() == true + ? node.FileRef.FileName + : node.GetValue(null as ITypeResolutionService).ToString(); + yield return new CreateOrUpdateResourceCommand { Comment = comment, Customer = query.Customer, Id = name, Industry = query.Industry, Language = query.TargetLanguage, ResourceSet = query.ResourceSet, - Textfile = null, Value = value }; - resultList.Add(resource); } } } - return new ImportResourcesCommand(resultList); + else { + _logger.ResxFileNotFound(query.Path); + } } } diff --git a/idee5.Globalization/Queries/ResxFileInputHandlerQuery.cs b/idee5.Globalization/Queries/ResxFileInputHandlerQuery.cs index f9d765f..cc3a74e 100644 --- a/idee5.Globalization/Queries/ResxFileInputHandlerQuery.cs +++ b/idee5.Globalization/Queries/ResxFileInputHandlerQuery.cs @@ -14,4 +14,4 @@ namespace idee5.Globalization.Queries; /// The customer parmance the resource file belongs to /// The language the resource file belongs to. If null if will be inferred from the file name. /// E.g. CommonTerms-de.resx -> language de -public record ResxFileInputHandlerQuery([Required] string Path, string ResourceSet, string? Industry, string? Customer, string? TargetLanguage) : IQuery; +public record ResxFileInputHandlerQuery([Required] string Path, string ResourceSet, string? Industry, string? Customer, string? TargetLanguage) : IQuery; diff --git a/idee5.Globalization/idee5.Globalization.csproj b/idee5.Globalization/idee5.Globalization.csproj index a99a9b7..1c031ac 100644 --- a/idee5.Globalization/idee5.Globalization.csproj +++ b/idee5.Globalization/idee5.Globalization.csproj @@ -13,9 +13,9 @@ Globalization extensions. Enables database support for localization resources and parlances for industries and customers.. idee5 © idee5 2016 - 2024 - 3.3.1 + 3.4.0 idee5, Globalization, Localization - Search queries added + Resx import updated to AsyncDataImporter enable Bernd Dongus Globalization tool for parlances for industries and customers