The last open item on the M7 list. Number and currency formatting was already locale-aware, but every string in the UI was an English literal, so a German instance read half in each language -- German data, English chrome. This translates all of it and adds the machinery to keep it translated. Strings live in Localization/Strings.resx (English, neutral) and Strings.de.resx. The neutral file generates a strongly-typed accessor at build time, aliased as S in _Imports.razor, so components reference compiled properties -- @S.Common_Save, not a string key. That choice is the point: across 4,500 lines of markup, a key lookup that silently falls back to its own name is a defect you find in production, while a renamed property is a build error. Generation runs in MSBuild rather than the IDE designer, so dotnet build alone reproduces it anywhere. Resource fallback is the hazard here. Ask for a key the German satellite lacks and ResourceManager quietly serves the English one -- correct at runtime, disastrous at release time, because a half-translated build looks perfectly healthy. StringResourceTests reads each satellite with tryParents: false, which is the only way to see what one actually contains, and fails on a missing or blank translation, a placeholder that changed arity, an orphan, or a key nothing references. Three things needed more than substitution: - Domain enums reached the screen as bare identifiers. They stay bare in the model -- they are persisted as text and appear in the REST API, so their names are part of the data contract -- and DisplayNames is now the single place that decides how each value is spoken. Every arm ends in a fallback returning the identifier, so a value added later cannot throw mid-render; EnumDisplayNameTests is what stops that safety net quietly becoming the shipping behaviour. - Infrastructure was writing display text: FlowService's "Other (X)", MeterPeriodView's "Generation"/"Consumption", the HA connection-test verdicts, the updater's snackbar, the CSV importer's row warnings. Each now returns an outcome value and the UI supplies the words, which is where the reader's language is known. Diagnostics that are not ours -- an HTTP status, systemd's stderr, an exception message -- are passed through untranslated, and every English summary is kept alongside the outcome so log lines never move with the UI language. The UpdateRunner change is additive only; no gate was touched. - Importer warnings carry their arguments rather than a finished sentence, so the numbers inside them pick up the reader's grouping. A register that reads 2.940,19 everywhere else must not read 2940.19 only inside a warning. Switching language is a redirect through /culture/set followed by a full reload, not an interactive state change: a Blazor Server circuit is fixed to the culture of the request that opened it. That makes the endpoint a redirector taking its target from the query string, so anything but a local path is refused rather than followed. Preference order is the cookie, then Accept-Language, then MeterVault__Locale -- an instance can be pinned to one language and a reader can still switch. Locale keeps its documented default of "en". Format now follows CurrentCulture instead of a hardcoded de-DE, so an instance with nothing configured and a browser asking for English will show English number formatting where it previously showed German; set MeterVault__Locale=de to pin the old behaviour. The importer's de-DE parsing is untouched and stays that way -- that dialect is a property of the spreadsheets, not of whoever is looking at the dashboard. Anything that comes from the database -- meter names, energy-type display names, category names -- is user data and is never translated. Claude-Session: https://claude.ai/code/session_0112ezeWqaZ85kTj5bYu9JHx
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
using Microsoft.AspNetCore.Localization;
|
||||
|
||||
namespace MeterVault.App.Localization;
|
||||
|
||||
/// <summary>The endpoint behind the language picker in the app bar.</summary>
|
||||
public static class CultureEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists a UI language and returns the user to where they were.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A redirect rather than an interactive state change on purpose: a Blazor Server circuit
|
||||
/// captures <see cref="System.Globalization.CultureInfo.CurrentUICulture"/> from the request
|
||||
/// that opened it, so switching language has to re-establish the circuit. The picker therefore
|
||||
/// navigates here with <c>forceLoad</c>, this writes the culture cookie the localization
|
||||
/// middleware reads, and the reload comes back in the new language.
|
||||
/// </remarks>
|
||||
public static IEndpointRouteBuilder MapCultureEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
endpoints.MapGet("/culture/set", (HttpContext http, string? culture, string? redirectUri) =>
|
||||
{
|
||||
// Only ever store a language we ship, so a hand-edited link can't park an unusable
|
||||
// culture in the cookie and leave the UI stuck in fallback.
|
||||
if (!Loc.TryResolve(culture, out var resolved))
|
||||
{
|
||||
return Results.BadRequest($"Unsupported culture '{culture}'.");
|
||||
}
|
||||
|
||||
http.Response.Cookies.Append(
|
||||
CookieRequestCultureProvider.DefaultCookieName,
|
||||
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(resolved)),
|
||||
new CookieOptions
|
||||
{
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
||||
SameSite = SameSiteMode.Lax,
|
||||
// Read only by the localization middleware, never by script.
|
||||
HttpOnly = true,
|
||||
// A language preference is exempt from consent gating, and the app is
|
||||
// self-hosted and single-user anyway.
|
||||
IsEssential = true,
|
||||
});
|
||||
|
||||
// Anything but a local path is refused rather than followed: this endpoint takes a
|
||||
// redirect target from the query string, which is exactly the shape of an open redirect.
|
||||
return Results.LocalRedirect(IsLocalPath(redirectUri) ? redirectUri! : "/");
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The framework's own local-URL rule: rooted, and not the "//host" or "/\host" forms browsers
|
||||
/// resolve as protocol-relative absolute URLs.
|
||||
/// </summary>
|
||||
private static bool IsLocalPath(string? url) =>
|
||||
!string.IsNullOrEmpty(url)
|
||||
&& url[0] == '/'
|
||||
&& (url.Length == 1 || (url[1] != '/' && url[1] != '\\'));
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Import;
|
||||
using MeterVault.Infrastructure.Update;
|
||||
|
||||
namespace MeterVault.App.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Human wording for the domain enums the UI puts on screen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The enums themselves stay bare identifiers: they are persisted as text (SDD §5.2) and appear in
|
||||
/// the REST API, so their names are part of the data contract and must not move with the reader's
|
||||
/// language. This is the one place that decides how each value is <em>spoken</em>, which keeps the
|
||||
/// domain layer free of presentation and gives every page the same word for the same concept —
|
||||
/// a <c>TariffComponent.UnitPrice</c> is "Arbeitspreis" in the table, the dropdown and the
|
||||
/// confirm dialog alike.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every arm resolves a <c>Enum_<Type>_<Value></c> resource. The fallback arms return the
|
||||
/// identifier rather than throwing, so adding an enum value can never crash a dashboard — and
|
||||
/// <c>EnumDisplayNameTests</c> fails the build if one is ever left without a translation, which is
|
||||
/// what stops that safety net from quietly becoming the shipping behaviour.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DisplayNames
|
||||
{
|
||||
/// <summary>The enums this class is responsible for; the resource-coverage test walks this list.</summary>
|
||||
public static IReadOnlyList<Type> LocalizedEnums { get; } =
|
||||
[
|
||||
typeof(MeterMode),
|
||||
typeof(ConsumptionKind),
|
||||
typeof(ReadingQuality),
|
||||
typeof(ReadingFlags),
|
||||
typeof(MeterEventType),
|
||||
typeof(SourceType),
|
||||
typeof(SourceValueKind),
|
||||
typeof(TariffScope),
|
||||
typeof(TariffComponent),
|
||||
typeof(TankRateMode),
|
||||
typeof(EndpointType),
|
||||
typeof(MappingRole),
|
||||
typeof(UpdateAvailability),
|
||||
];
|
||||
|
||||
public static string Display(this MeterMode value) => value switch
|
||||
{
|
||||
MeterMode.CumulativeCounter => Strings.Enum_MeterMode_CumulativeCounter,
|
||||
MeterMode.GenerationCounter => Strings.Enum_MeterMode_GenerationCounter,
|
||||
MeterMode.RuntimeCounter => Strings.Enum_MeterMode_RuntimeCounter,
|
||||
MeterMode.ConsumableBalance => Strings.Enum_MeterMode_ConsumableBalance,
|
||||
MeterMode.DirectDelta => Strings.Enum_MeterMode_DirectDelta,
|
||||
MeterMode.InstantRate => Strings.Enum_MeterMode_InstantRate,
|
||||
MeterMode.Virtual => Strings.Enum_MeterMode_Virtual,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this ConsumptionKind value) => value switch
|
||||
{
|
||||
ConsumptionKind.Consumption => Strings.Enum_ConsumptionKind_Consumption,
|
||||
ConsumptionKind.Generation => Strings.Enum_ConsumptionKind_Generation,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this ReadingQuality value) => value switch
|
||||
{
|
||||
ReadingQuality.Measured => Strings.Enum_ReadingQuality_Measured,
|
||||
ReadingQuality.Estimated => Strings.Enum_ReadingQuality_Estimated,
|
||||
ReadingQuality.Manual => Strings.Enum_ReadingQuality_Manual,
|
||||
ReadingQuality.Imported => Strings.Enum_ReadingQuality_Imported,
|
||||
ReadingQuality.Interpolated => Strings.Enum_ReadingQuality_Interpolated,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A bitmask rendered as the set flags, comma-joined. <see cref="ReadingFlags.None"/> gives an
|
||||
/// empty string: the readings table shows a flag column that is blank for almost every row, and
|
||||
/// printing "None" a thousand times down a page is noise, not information.
|
||||
/// </summary>
|
||||
public static string Display(this ReadingFlags value)
|
||||
{
|
||||
if (value == ReadingFlags.None)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var names = new List<string>(3);
|
||||
if (value.HasFlag(ReadingFlags.CounterReset))
|
||||
{
|
||||
names.Add(Strings.Enum_ReadingFlags_CounterReset);
|
||||
}
|
||||
|
||||
if (value.HasFlag(ReadingFlags.MeterSwap))
|
||||
{
|
||||
names.Add(Strings.Enum_ReadingFlags_MeterSwap);
|
||||
}
|
||||
|
||||
if (value.HasFlag(ReadingFlags.Anomaly))
|
||||
{
|
||||
names.Add(Strings.Enum_ReadingFlags_Anomaly);
|
||||
}
|
||||
|
||||
return names.Count > 0 ? string.Join(", ", names) : value.ToString();
|
||||
}
|
||||
|
||||
public static string Display(this MeterEventType value) => value switch
|
||||
{
|
||||
MeterEventType.MeterSwap => Strings.Enum_MeterEventType_MeterSwap,
|
||||
MeterEventType.CounterReset => Strings.Enum_MeterEventType_CounterReset,
|
||||
MeterEventType.Delivery => Strings.Enum_MeterEventType_Delivery,
|
||||
MeterEventType.TankLevel => Strings.Enum_MeterEventType_TankLevel,
|
||||
MeterEventType.Correction => Strings.Enum_MeterEventType_Correction,
|
||||
MeterEventType.Note => Strings.Enum_MeterEventType_Note,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this SourceType value) => value switch
|
||||
{
|
||||
SourceType.Mqtt => Strings.Enum_SourceType_Mqtt,
|
||||
SourceType.Tasmota => Strings.Enum_SourceType_Tasmota,
|
||||
SourceType.HomeAssistant => Strings.Enum_SourceType_HomeAssistant,
|
||||
SourceType.Manual => Strings.Enum_SourceType_Manual,
|
||||
SourceType.Import => Strings.Enum_SourceType_Import,
|
||||
SourceType.Virtual => Strings.Enum_SourceType_Virtual,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this SourceValueKind value) => value switch
|
||||
{
|
||||
SourceValueKind.Register => Strings.Enum_SourceValueKind_Register,
|
||||
SourceValueKind.Delta => Strings.Enum_SourceValueKind_Delta,
|
||||
SourceValueKind.Rate => Strings.Enum_SourceValueKind_Rate,
|
||||
SourceValueKind.Level => Strings.Enum_SourceValueKind_Level,
|
||||
SourceValueKind.Runtime => Strings.Enum_SourceValueKind_Runtime,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this TariffScope value) => value switch
|
||||
{
|
||||
TariffScope.Global => Strings.Enum_TariffScope_Global,
|
||||
TariffScope.EnergyType => Strings.Enum_TariffScope_EnergyType,
|
||||
TariffScope.Meter => Strings.Enum_TariffScope_Meter,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this TariffComponent value) => value switch
|
||||
{
|
||||
TariffComponent.UnitPrice => Strings.Enum_TariffComponent_UnitPrice,
|
||||
TariffComponent.BasePrice => Strings.Enum_TariffComponent_BasePrice,
|
||||
TariffComponent.FeedIn => Strings.Enum_TariffComponent_FeedIn,
|
||||
TariffComponent.Bonus => Strings.Enum_TariffComponent_Bonus,
|
||||
TariffComponent.Discount => Strings.Enum_TariffComponent_Discount,
|
||||
TariffComponent.Tax => Strings.Enum_TariffComponent_Tax,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this TankRateMode value) => value switch
|
||||
{
|
||||
TankRateMode.Fixed => Strings.Enum_TankRateMode_Fixed,
|
||||
TankRateMode.Empirical => Strings.Enum_TankRateMode_Empirical,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this EndpointType value) => value switch
|
||||
{
|
||||
EndpointType.MqttBroker => Strings.Enum_EndpointType_MqttBroker,
|
||||
EndpointType.HomeAssistant => Strings.Enum_EndpointType_HomeAssistant,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this UpdateAvailability value) => value switch
|
||||
{
|
||||
UpdateAvailability.Allowed => Strings.Enum_UpdateAvailability_Allowed,
|
||||
UpdateAvailability.NotEnabled => Strings.Enum_UpdateAvailability_NotEnabled,
|
||||
UpdateAvailability.NotSupportedHere => Strings.Enum_UpdateAvailability_NotSupportedHere,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this MappingRole value) => value switch
|
||||
{
|
||||
MappingRole.Ignore => Strings.Enum_MappingRole_Ignore,
|
||||
MappingRole.Reading => Strings.Enum_MappingRole_Reading,
|
||||
MappingRole.Delivery => Strings.Enum_MappingRole_Delivery,
|
||||
MappingRole.TankLevel => Strings.Enum_MappingRole_TankLevel,
|
||||
MappingRole.ManualCost => Strings.Enum_MappingRole_ManualCost,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MeterVault.Infrastructure.Import;
|
||||
|
||||
namespace MeterVault.App.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Says a staging warning in the reader's language.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The importer emits <see cref="ImportWarning"/> values carrying an English sentence plus the
|
||||
/// arguments that filled it (see <c>ImportWarnings</c>). Re-formatting from the arguments rather
|
||||
/// than translating the finished sentence is what lets the numbers pick up the reader's digit
|
||||
/// grouping — a register that reads <c>2.940,19</c> everywhere else must not read <c>2940.19</c>
|
||||
/// only inside a warning.
|
||||
/// </remarks>
|
||||
public static class ImportWarningText
|
||||
{
|
||||
public static string Display(this ImportWarning warning)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(warning);
|
||||
|
||||
var args = warning.Args as object?[] ?? [.. warning.Args];
|
||||
|
||||
return warning.Kind switch
|
||||
{
|
||||
ImportWarningKind.RowSkipped => Loc.F(Strings.ImportWarning_RowSkipped, args),
|
||||
ImportWarningKind.UnparseableDate => Loc.F(Strings.ImportWarning_UnparseableDate, args),
|
||||
ImportWarningKind.UnitMismatch => Loc.F(Strings.ImportWarning_UnitMismatch, args),
|
||||
ImportWarningKind.RegisterDropped => Loc.F(Strings.ImportWarning_RegisterDropped, args),
|
||||
ImportWarningKind.RegisterDroppedWithAmount =>
|
||||
Loc.F(Strings.ImportWarning_RegisterDroppedWithAmount, args),
|
||||
|
||||
// A kind added later still says something useful rather than blanking the panel.
|
||||
_ => warning.Message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace MeterVault.App.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// UI-language plumbing around <see cref="Strings"/>, the strongly-typed accessor MSBuild generates
|
||||
/// from <c>Strings.resx</c> (see the <c>EmbeddedResource</c> block in the project file).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The neutral resource is English and every other language ships as a satellite assembly, so an
|
||||
/// <c>Accept-Language</c> we don't translate degrades to English rather than to raw resource keys.
|
||||
/// Strings are referenced as compiled properties (<c>S.Common_Save</c>), not string lookups, so a
|
||||
/// key that no longer exists is a build error instead of a mystery label at runtime.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Number and date <em>formatting</em> follows <see cref="CultureInfo.CurrentCulture"/> and the UI
|
||||
/// language follows <see cref="CultureInfo.CurrentUICulture"/>; the request-localization middleware
|
||||
/// sets both from the same choice, so the two never disagree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Loc
|
||||
{
|
||||
/// <summary>Cultures the UI ships translations for. The first entry is the neutral fallback.</summary>
|
||||
public static IReadOnlyList<string> SupportedCultures { get; } = ["en", "de"];
|
||||
|
||||
/// <summary>Formats a resource carrying <c>{0}</c>-style placeholders in the request's culture.</summary>
|
||||
public static string F(string format, params object?[] args) =>
|
||||
string.Format(CultureInfo.CurrentCulture, format, args);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a requested language onto one we actually ship, matching on the two-letter tag so
|
||||
/// <c>de-AT</c> and <c>de-CH</c> get German instead of falling through to English.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> when the request named a language we translate.</returns>
|
||||
public static bool TryResolve(string? requested, out string resolved)
|
||||
{
|
||||
resolved = SupportedCultures[0];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requested))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = requested.Trim();
|
||||
var separator = trimmed.IndexOfAny(['-', '_']);
|
||||
var language = separator < 0 ? trimmed : trimmed[..separator];
|
||||
|
||||
foreach (var supported in SupportedCultures)
|
||||
{
|
||||
if (string.Equals(supported, language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
resolved = supported;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>The display name of a supported culture, written in that language ("Deutsch", "English").</summary>
|
||||
public static string DisplayName(string culture) =>
|
||||
CultureInfo.GetCultureInfo(culture).NativeName;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user