Skip to content

Commit

Permalink
WebAppLocalization: PO localization implemented
Browse files Browse the repository at this point in the history
  • Loading branch information
adams85 committed Sep 25, 2021
1 parent 403907f commit 54f20e1
Show file tree
Hide file tree
Showing 31 changed files with 1,132 additions and 33 deletions.
12 changes: 12 additions & 0 deletions samples/WebAppLocalization/.config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"karambolo.aspnetskeleton.potools": {
"version": "1.0.0",
"commands": [
"po"
]
}
}
}
24 changes: 24 additions & 0 deletions samples/WebAppLocalization/AppLocalizationOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;

namespace WebApp
{
public class AppLocalizationOptions
{
private static CultureInfo[]? s_defaultSupportedCultureInfos;
private static CultureInfo[] DefaultSupportedCultureInfos => LazyInitializer.EnsureInitialized(ref s_defaultSupportedCultureInfos, () => new[] { new CultureInfo("en-US") });

public string[]? SupportedCultures
{
get => _supportedCultureInfos?.Select(cultureInfo => cultureInfo.Name).ToArray();
set => _supportedCultureInfos = value?.Length > 0 ? value.Select(culture => new CultureInfo(culture)).ToArray() : null;
}

private CultureInfo[]? _supportedCultureInfos;
public IReadOnlyList<CultureInfo> SupportedCultureInfos => _supportedCultureInfos ?? DefaultSupportedCultureInfos;

public CultureInfo DefaultCultureInfo => SupportedCultureInfos[0];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Collections.Concurrent;
using System.Reflection;

namespace WebApp.Infrastructure
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Delegate | AttributeTargets.Enum | AttributeTargets.Interface | AttributeTargets.Struct, AllowMultiple = false, Inherited = true)]
public sealed class AssociatedAssemblyNameAttribute : Attribute
{
private static readonly ConcurrentDictionary<Type, AssociatedAssemblyNameAttribute?> s_attributeCache = new ConcurrentDictionary<Type, AssociatedAssemblyNameAttribute?>();

public static AssociatedAssemblyNameAttribute? GetCachedFor(Type type) => s_attributeCache.GetOrAdd(type, type => type.GetCustomAttribute<AssociatedAssemblyNameAttribute>());

public AssociatedAssemblyNameAttribute(string assemblyName)
{
AssemblyName = new AssemblyName(assemblyName);
}

public AssemblyName AssemblyName { get; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Karambolo.PO;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace WebApp.Infrastructure.Localization
{
public class DefaultTranslationsProvider : ITranslationsProvider
{
private const string FileNamePattern = "*.po";

private static readonly POParserSettings s_parserSettings = new POParserSettings
{
SkipComments = true,
SkipInfoHeaders = true,
StringDecodingOptions = new POStringDecodingOptions { KeepKeyStringsPlatformIndependent = true }
};

private readonly ILogger _logger;

private readonly string _translationsBasePath;
private readonly IReadOnlyDictionary<(string Location, string Culture), POCatalog> _catalogs;

public DefaultTranslationsProvider(ILogger<DefaultTranslationsProvider> logger)
{
_logger = logger ?? (ILogger)NullLogger.Instance;

_translationsBasePath = Path.Combine(AppContext.BaseDirectory, "Translations");
_catalogs = LoadFiles();
}

private Dictionary<(string Location, string Culture), POCatalog> LoadFiles()
{
return Directory.GetFiles(_translationsBasePath, FileNamePattern, SearchOption.AllDirectories)
.Select(LoadFile)
.Where(item => item != null)
.ToDictionary(item => (item!.Value.Location, item.Value.Culture), item => item!.Value.Catalog);
}

private (POCatalog Catalog, string Location, string Culture)? LoadFile(string filePath)
{
var relativeFilePath = Path.GetRelativePath(_translationsBasePath, filePath);

var culture = Path.GetDirectoryName(relativeFilePath);
if (string.IsNullOrEmpty(culture) || !string.IsNullOrEmpty(Path.GetDirectoryName(culture)))
return null;

var location = Path.GetFileNameWithoutExtension(relativeFilePath);

var catalog = LoadTranslations(filePath);
if (catalog == null)
return null;

return (catalog, location, culture);
}

private POCatalog? LoadTranslations(string filePath)
{
FileStream fileStream;
try { fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); }
catch (Exception ex) when (ex is DirectoryNotFoundException || ex is FileNotFoundException)
{
_logger.LogWarning(ex, "Translation file \"{PATH}\" cannot be accessed.", filePath);
return null;
}

POParseResult parseResult;
using (fileStream)
parseResult = new POParser(s_parserSettings).Parse(fileStream);

if (!parseResult.Success)
{
var diagnosticMessages = parseResult.Diagnostics
.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error);

_logger.LogWarning("Translation file \"{PATH}\" has errors: {ERRORS}", filePath, string.Join(Environment.NewLine, diagnosticMessages));
return null;
}

return parseResult.Catalog;
}

public IReadOnlyDictionary<(string Location, string Culture), POCatalog> GetCatalogs() => _catalogs;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Mvc.Localization;

namespace WebApp.Infrastructure.Localization
{
public sealed class ExtendedHtmlLocalizer : HtmlLocalizer
{
private readonly IExtendedStringLocalizer _stringLocalizer;

public ExtendedHtmlLocalizer(IExtendedStringLocalizer stringLocalizer) : base(stringLocalizer)
{
_stringLocalizer = stringLocalizer;
}

public override LocalizedHtmlString this[string name]
{
get
{
var translation = _stringLocalizer.GetTranslation(name, default, default, out var _, out var resourceNotFound);
return new LocalizedHtmlString(name, translation, resourceNotFound);
}
}

public override LocalizedHtmlString this[string name, params object[] arguments]
{
get
{
var (plural, context) = LocalizationHelper.GetSpecialArgs(arguments);
var translation = _stringLocalizer.GetTranslation(name, plural, context, out var _, out var resourceNotFound);
return new LocalizedHtmlString(name, translation, resourceNotFound, arguments);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.Extensions.Localization;

namespace WebApp.Infrastructure.Localization
{
public sealed class ExtendedHtmlLocalizerFactory : IHtmlLocalizerFactory
{
private readonly IStringLocalizerFactory _stringLocalizerFactory;

public ExtendedHtmlLocalizerFactory(IStringLocalizerFactory stringLocalizerFactory)
{
_stringLocalizerFactory = stringLocalizerFactory ?? throw new ArgumentNullException(nameof(stringLocalizerFactory));
}

private static IHtmlLocalizer CreateHtmlLocalizer(IStringLocalizer stringLocalizer) =>
stringLocalizer is IExtendedStringLocalizer extendedStringLocalizer ?
new ExtendedHtmlLocalizer(extendedStringLocalizer) :
new HtmlLocalizer(stringLocalizer);

public IHtmlLocalizer Create(string baseName, string location) => CreateHtmlLocalizer(_stringLocalizerFactory.Create(baseName, location));

public IHtmlLocalizer Create(Type resourceSource) => CreateHtmlLocalizer(_stringLocalizerFactory.Create(resourceSource));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.Extensions.Localization;

namespace WebApp.Infrastructure.Localization
{
public sealed class ExtendedViewLocalizer : IViewLocalizer, IViewContextAware
{
private readonly IHtmlLocalizerFactory _localizerFactory;
private readonly string _applicationName;
private IHtmlLocalizer? _localizer;

public ExtendedViewLocalizer(IHtmlLocalizerFactory localizerFactory, IWebHostEnvironment hostingEnvironment)
{
_localizerFactory = localizerFactory ?? throw new ArgumentNullException(nameof(localizerFactory));
_applicationName = (hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment))).ApplicationName;
}

public LocalizedHtmlString this[string key] => _localizer![key];

public LocalizedHtmlString this[string key, params object[] arguments] => _localizer![key, arguments];

public LocalizedString GetString(string name) => _localizer!.GetString(name);

public LocalizedString GetString(string name, params object[] values) => _localizer!.GetString(name, values);

#if !NET5_0_OR_GREATER
[Obsolete("This method is obsolete. Use `CurrentCulture` and `CurrentUICulture` instead.")]
public IHtmlLocalizer WithCulture(CultureInfo culture) => _localizer!.WithCulture(culture);
#endif

public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) =>
_localizer!.GetAllStrings(includeParentCultures);

public void Contextualize(ViewContext viewContext)
{
if (viewContext == null)
throw new ArgumentNullException(nameof(viewContext));

// Given a view path "/Views/Home/Index.cshtml" we want a baseName like "MyApplication.Views.Home.Index"
var path = viewContext.ExecutingFilePath;

if (string.IsNullOrEmpty(path))
{
path = viewContext.View.Path;
}

Debug.Assert(!string.IsNullOrEmpty(path), "Couldn't determine a path for the view");

var location =
(viewContext.View is RazorView razorView ? AssociatedAssemblyNameAttribute.GetCachedFor(razorView.RazorPage.GetType())?.AssemblyName.Name : null) ??
_applicationName;

_localizer = _localizerFactory.Create(BuildBaseName(path, location), location);
}

private static string BuildBaseName(string path, string location)
{
var extension = Path.GetExtension(path);
var startIndex = path[0] == '/' || path[0] == '\\' ? 1 : 0;
var length = path.Length - startIndex - extension.Length;
var capacity = length + location.Length + 1;
var builder = new StringBuilder(path, startIndex, length, capacity);

builder.Replace('/', '.').Replace('\\', '.');

// Prepend the application name
builder.Insert(0, '.');
builder.Insert(0, location);

return builder.ToString();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Diagnostics.CodeAnalysis;
using Karambolo.Common.Localization;
using Microsoft.Extensions.Localization;

namespace WebApp.Infrastructure.Localization
{
public interface IExtendedStringLocalizer : IStringLocalizer
{
string GetTranslation(string name, Plural plural, TextContext context, out string? searchedLocation, out bool resourceNotFound);
bool TryGetTranslation(string name, Plural plural, TextContext context, out string? searchedLocation, [MaybeNullWhen(false)] out string value);

bool TryLocalize(string name, out string? searchedLocation, [MaybeNullWhen(false)] out string value);
bool TryLocalize(string name, object[] arguments, out string? searchedLocation, [MaybeNullWhen(false)] out string value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using Karambolo.PO;

namespace WebApp.Infrastructure.Localization
{
public interface ITranslationsProvider
{
IReadOnlyDictionary<(string Location, string Culture), POCatalog> GetCatalogs();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
using Karambolo.Common.Localization;
using Microsoft.Extensions.Logging;

namespace WebApp.Infrastructure.Localization
{
public static class LocalizationHelper
{
private static readonly ConcurrentDictionary<(string, string?), object?> s_unavailableTranslations = new ConcurrentDictionary<(string, string?), object?>();

internal static void TranslationNotAvailable(this ILogger logger, string name, CultureInfo culture, string? searchedLocation)
{
if (s_unavailableTranslations.TryAdd((name, searchedLocation), default))
logger.LogWarning("Translation for '{NAME}' in culture '{CULTURE}' was not found at the following location(s): {LOCATION}.", name, culture.Name, searchedLocation ?? "(n/a)");
}

public static (Plural, TextContext) GetSpecialArgs(object[] args)
{
var plural = (Plural?)Array.Find(args, arg => arg is Plural);
var context = args.Length > 0 ? args[^1] as TextContext? : null;

return (plural ?? default, context ?? default);
}
}
}
Loading

0 comments on commit 54f20e1

Please sign in to comment.