From 81cf6d1cab11382bbbf1b94f35487fb4ec2de1df Mon Sep 17 00:00:00 2001 From: tombogle Date: Thu, 3 Sep 2026 18:47:54 -0400 Subject: [PATCH 1/3] +semver:minor Replace retired BingTranslator with MyMemoryTranslator/MicrosoftTranslator The Bing/Microsoft Translator v1 SOAP API used by the internal BingTranslator class (LanguageChoosingDialog's fail-safe, on-the-fly translation of its title/message/OK button) was retired years ago, so this had been silently falling back to English the whole time. Replaces it with: - MyMemoryTranslator (internal, new default): a free, keyless REST API with no provisioning required, replacing the also-dead GoogleTranslator.cs in place. - MicrosoftTranslator (new public class): the real Azure AI Translator v3 REST API, opt-in via a subscription key (MicrosoftTranslator.SubscriptionKey or the L10NSHARP_TRANSLATOR_KEY/L10NSHARP_TRANSLATOR_REGION env vars), for host apps that want more robust translation. TranslatorBase is now public (required for MicrosoftTranslator to subclass it publicly). LanguageChoosingDialog picks MicrosoftTranslator when configured, else MyMemoryTranslator. Also removes BingTranslator.cs, the generated WCF service reference, and the System.ServiceModel/System.Security.Cryptography.Xml dependencies they required, since nothing REST-based needs WCF. Adds a "Show Language Chooser" button to the SampleApp (randomly demoing "de", "it", or "fr") so the fix is visible without faking a missing-locale scenario. "ar" is deliberately excluded: LanguageChoosingDialog doesn't set RightToLeft, so Arabic would demo a layout bug rather than the translator. Fixes #163. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 9 +- .../Translators/MicrosoftTranslatorTests.cs | 63 ++++++ .../L10NSharp.Windows.Forms.csproj | 9 - .../BingTranslatorService/Reference.cs | 86 -------- .../BingTranslatorService/Reference.svcmap | 32 --- .../BingTranslatorService/Soap.wsdl | 96 --------- .../BingTranslatorService/Soap.xsd | 65 ------- .../BingTranslatorService/Soap1.xsd | 9 - .../BingTranslatorService/Soap2.xsd | 42 ---- .../configuration.svcinfo | 10 - .../configuration91.svcinfo | 183 ------------------ .../Translators/BingTranslator.cs | 110 ----------- .../Translators/MicrosoftTranslator.cs | 150 ++++++++++++++ ...gleTranslator.cs => MyMemoryTranslator.cs} | 34 ++-- .../Translators/TranslatorBase.cs | 2 +- .../UIComponents/LanguageChoosingDialog.cs | 8 +- src/L10NSharp/app.config | 16 -- src/SampleApp/Form1.Designer.cs | 18 +- src/SampleApp/Form1.cs | 19 ++ 19 files changed, 287 insertions(+), 674 deletions(-) create mode 100644 src/L10NSharp.Windows.Forms.Tests/Translators/MicrosoftTranslatorTests.cs delete mode 100644 src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Reference.cs delete mode 100644 src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Reference.svcmap delete mode 100644 src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap.wsdl delete mode 100644 src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap.xsd delete mode 100644 src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap1.xsd delete mode 100644 src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap2.xsd delete mode 100644 src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/configuration.svcinfo delete mode 100644 src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/configuration91.svcinfo delete mode 100644 src/L10NSharp.Windows.Forms/Translators/BingTranslator.cs create mode 100644 src/L10NSharp.Windows.Forms/Translators/MicrosoftTranslator.cs rename src/L10NSharp.Windows.Forms/Translators/{GoogleTranslator.cs => MyMemoryTranslator.cs} (66%) diff --git a/CHANGELOG.md b/CHANGELOG.md index c80cab07..c5cd8116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,10 +26,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - BREAKING CHANGE: [L10NSharp] [L10NSharp.Windows.Forms] [SampleApp] [CheckOrFixXliff] [ExtractXliff] Replaced the `net461` target framework with `net462`. The `System.Resources.Extensions` version raised transitively by the `SIL.ReleaseTasks` upgrade (above) no longer ships a `net461`-specific assembly, so `net461` is no longer a supported or tested target. Projects that still need to target `net461` should continue using the last release built for it, or upgrade to at least `net462`. +### Fixed + +- [L10NSharp.Windows.Forms] Fixed `LanguageChoosingDialog`'s fail-safe, on-the-fly translation of its title/message/OK button, which had been silently broken since the Bing/Microsoft Translator v1 SOAP API it used was retired. It now uses the free, keyless MyMemory Translation API by default; host apps that want more robust translation can opt in to the new public `MicrosoftTranslator` class (Azure AI Translator v3) by setting a subscription key. See [#163](https://github.com/sillsdev/l10nsharp/issues/163). The SampleApp has a "Show Language Chooser" button so you can see it in action without needing to fake a missing-locale scenario. + +### Removed + +- [L10NSharp.Windows.Forms] Removed the internal `BingTranslator` class and its generated WCF service reference, along with the `System.ServiceModel`/`System.Security.Cryptography.Xml` dependencies they required, since the API they called has been retired. See "Fixed", above. + ### Security - [L10NSharp] [L10NSharp.Windows.Forms] [CheckOrFixXliff] [ExtractXliff] Upgraded `SIL.ReleaseTasks` from 2.5.0 to 3.3.0. This also raises the resolved version of `System.Resources.Extensions` (a transitive dependency of `SIL.ReleaseTasks`) from 6.0.0 to 10.0.11. Note: `SIL.ReleaseTasks` 3.3.0 has a known, build-time-only dependency on a vulnerable `Newtonsoft.Json` (via a temporary revert of its own `SIL.Core` dependency, pending an upstream `Mono.Unix` packaging issue) — per the upstream maintainers this is not an exploitable runtime risk, since the package is build-tool-only and never ships in L10nSharp's own output, and no L10nSharp or SIL.ReleaseTasks build step feeds it untrusted JSON. See [sillsdev/SIL.BuildTasks#88](https://github.com/sillsdev/SIL.BuildTasks/pull/88) for details. -- [L10NSharp.Windows.Forms] Upgraded `System.ServiceModel.Http` and `System.ServiceModel.Primitives` from 6.2.0 to 8.1.2, and added a direct `System.Security.Cryptography.Xml` reference pinned to 8.0.4. `System.ServiceModel.Primitives` 8.1.2 resolves a vulnerable 8.0.2 of `System.Security.Cryptography.Xml` transitively on `net8.0-windows`; the direct reference overrides it with the latest patched 8.0.x release. ## [10.0.0] - 2026-08-27 diff --git a/src/L10NSharp.Windows.Forms.Tests/Translators/MicrosoftTranslatorTests.cs b/src/L10NSharp.Windows.Forms.Tests/Translators/MicrosoftTranslatorTests.cs new file mode 100644 index 00000000..c86e33d4 --- /dev/null +++ b/src/L10NSharp.Windows.Forms.Tests/Translators/MicrosoftTranslatorTests.cs @@ -0,0 +1,63 @@ +using System; +using L10NSharp.Windows.Forms.Translators; +using NUnit.Framework; + +namespace L10NSharp.Windows.Forms.Tests.Translators +{ + [TestFixture] + public class MicrosoftTranslatorTests + { + private const string kKeyEnvVar = "L10NSHARP_TRANSLATOR_KEY"; + private const string kRegionEnvVar = "L10NSHARP_TRANSLATOR_REGION"; + + [TearDown] + public void TearDown() + { + MicrosoftTranslator.SubscriptionKey = null; + MicrosoftTranslator.Region = null; + Environment.SetEnvironmentVariable(kKeyEnvVar, null); + Environment.SetEnvironmentVariable(kRegionEnvVar, null); + } + + [Test] + public void IsConfigured_NoKeySetAnywhere_ReturnsFalse() + { + Assert.That(MicrosoftTranslator.IsConfigured, Is.False); + } + + [Test] + public void IsConfigured_SubscriptionKeySetDirectly_ReturnsTrue() + { + MicrosoftTranslator.SubscriptionKey = "some-key"; + Assert.That(MicrosoftTranslator.IsConfigured, Is.True); + } + + [Test] + public void IsConfigured_SubscriptionKeySetViaEnvironmentVariable_ReturnsTrue() + { + Environment.SetEnvironmentVariable(kKeyEnvVar, "some-key"); + Assert.That(MicrosoftTranslator.IsConfigured, Is.True); + } + + [Test] + public void SubscriptionKey_SetDirectly_TakesPrecedenceOverEnvironmentVariable() + { + Environment.SetEnvironmentVariable(kKeyEnvVar, "env-key"); + MicrosoftTranslator.SubscriptionKey = "direct-key"; + Assert.That(MicrosoftTranslator.EffectiveSubscriptionKey, Is.EqualTo("direct-key")); + } + + [Test] + public void Region_NotSetDirectly_FallsBackToEnvironmentVariable() + { + Environment.SetEnvironmentVariable(kRegionEnvVar, "westus"); + Assert.That(MicrosoftTranslator.EffectiveRegion, Is.EqualTo("westus")); + } + + [Test] + public void Region_NeitherSet_IsNull() + { + Assert.That(MicrosoftTranslator.EffectiveRegion, Is.Null); + } + } +} diff --git a/src/L10NSharp.Windows.Forms/L10NSharp.Windows.Forms.csproj b/src/L10NSharp.Windows.Forms/L10NSharp.Windows.Forms.csproj index 32e0d443..621feffc 100644 --- a/src/L10NSharp.Windows.Forms/L10NSharp.Windows.Forms.csproj +++ b/src/L10NSharp.Windows.Forms/L10NSharp.Windows.Forms.csproj @@ -9,14 +9,8 @@ True - - - - - - @@ -74,8 +68,5 @@ Settings.Designer.cs SettingsSingleFileGenerator - - WCF Proxy Generator - diff --git a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Reference.cs b/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Reference.cs deleted file mode 100644 index 0ce15bd7..00000000 --- a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Reference.cs +++ /dev/null @@ -1,86 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace L10NSharp.Windows.Forms.BingTranslatorService { - - - [System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel", "4.0.0.0")] - [System.ServiceModel.ServiceContractAttribute(Namespace="http://api.microsofttranslator.com/v1/soap.svc", ConfigurationName="BingTranslatorService.LanguageService")] - public interface LanguageService { - - [System.ServiceModel.OperationContractAttribute(Action="http://api.microsofttranslator.com/v1/soap.svc/LanguageService/GetLanguages", ReplyAction="http://api.microsofttranslator.com/v1/soap.svc/LanguageService/GetLanguagesRespon" + - "se")] - string[] GetLanguages(string appId); - - [System.ServiceModel.OperationContractAttribute(Action="http://api.microsofttranslator.com/v1/soap.svc/LanguageService/GetLanguageNames", ReplyAction="http://api.microsofttranslator.com/v1/soap.svc/LanguageService/GetLanguageNamesRe" + - "sponse")] - string[] GetLanguageNames(string appId, string locale); - - [System.ServiceModel.OperationContractAttribute(Action="http://api.microsofttranslator.com/v1/soap.svc/LanguageService/Detect", ReplyAction="http://api.microsofttranslator.com/v1/soap.svc/LanguageService/DetectResponse")] - string Detect(string appId, string text); - - [System.ServiceModel.OperationContractAttribute(Action="http://api.microsofttranslator.com/v1/soap.svc/LanguageService/Translate", ReplyAction="http://api.microsofttranslator.com/v1/soap.svc/LanguageService/TranslateResponse")] - string Translate(string appId, string text, string from, string to); - } - - [System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel", "4.0.0.0")] - public interface LanguageServiceChannel : L10NSharp.Windows.Forms.BingTranslatorService.LanguageService, System.ServiceModel.IClientChannel { - } - - [System.Diagnostics.DebuggerStepThroughAttribute()] - [System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel", "4.0.0.0")] - public partial class LanguageServiceClient : System.ServiceModel.ClientBase, L10NSharp.Windows.Forms.BingTranslatorService.LanguageService { - - public LanguageServiceClient() { - } - - // MANUAL EDIT — NOT REGENERATED SAFE: the #if NETFRAMEWORK guard below was added by hand - // and will be silently dropped if this file is regenerated (e.g. via "Update Service - // Reference" from BingTranslatorService.svcmap). Without it, these config-name-based - // ClientBase constructors don't compile against the modern System.ServiceModel.Primitives - // package used for net8.0-windows (no config-based WCF client support), breaking the build - // exactly as it did before. If you regenerate this file, reapply this guard, or — better — - // finish the replacement of BingTranslator/this proxy with a plain REST client (see - // https://github.com/sillsdev/l10nsharp/issues/163), which removes this file entirely. -#if NETFRAMEWORK - public LanguageServiceClient(string endpointConfigurationName) : - base(endpointConfigurationName) { - } - - public LanguageServiceClient(string endpointConfigurationName, string remoteAddress) : - base(endpointConfigurationName, remoteAddress) { - } - - public LanguageServiceClient(string endpointConfigurationName, System.ServiceModel.EndpointAddress remoteAddress) : - base(endpointConfigurationName, remoteAddress) { - } -#endif - - public LanguageServiceClient(System.ServiceModel.Channels.Binding binding, System.ServiceModel.EndpointAddress remoteAddress) : - base(binding, remoteAddress) { - } - - public string[] GetLanguages(string appId) { - return base.Channel.GetLanguages(appId); - } - - public string[] GetLanguageNames(string appId, string locale) { - return base.Channel.GetLanguageNames(appId, locale); - } - - public string Detect(string appId, string text) { - return base.Channel.Detect(appId, text); - } - - public string Translate(string appId, string text, string from, string to) { - return base.Channel.Translate(appId, text, from, to); - } - } -} diff --git a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Reference.svcmap b/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Reference.svcmap deleted file mode 100644 index 2f84e9f7..00000000 --- a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Reference.svcmap +++ /dev/null @@ -1,32 +0,0 @@ - - - - false - true - - false - false - false - - - true - Auto - true - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap.wsdl b/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap.wsdl deleted file mode 100644 index 998aa486..00000000 --- a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap.wsdl +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap.xsd b/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap.xsd deleted file mode 100644 index b3c65457..00000000 --- a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap.xsd +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap1.xsd b/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap1.xsd deleted file mode 100644 index 08fed4c7..00000000 --- a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap1.xsd +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap2.xsd b/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap2.xsd deleted file mode 100644 index 3418318f..00000000 --- a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/Soap2.xsd +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/configuration.svcinfo b/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/configuration.svcinfo deleted file mode 100644 index 224e0a17..00000000 --- a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/configuration.svcinfo +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/configuration91.svcinfo b/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/configuration91.svcinfo deleted file mode 100644 index dbec6fe1..00000000 --- a/src/L10NSharp.Windows.Forms/Service References/BingTranslatorService/configuration91.svcinfo +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - BasicHttpBinding_LanguageService - - - 00:01:00 - - - 00:01:00 - - - 00:10:00 - - - 00:01:00 - - - False - - - False - - - StrongWildcard - - - 65536 - - - 524288 - - - 65536 - - - Text - - - - - - System.ServiceModel.Configuration.XmlDictionaryReaderQuotasElement - - - 32 - - - 8192 - - - 16384 - - - 4096 - - - 16384 - - - System.ServiceModel.Configuration.BasicHttpSecurityElement - - - None - - - System.ServiceModel.Configuration.HttpTransportSecurityElement - - - None - - - None - - - - - - System.ServiceModel.Configuration.BasicHttpMessageSecurityElement - - - UserName - - - Basic256 - - - System.Text.UTF8Encoding - - - Buffered - - - True - - - - - - - - - http://api.microsofttranslator.com/v1/Soap.svc - - - - - - basicHttpBinding - - - BasicHttpBinding_LanguageService - - - BingTranslatorService.LanguageService - - - System.ServiceModel.Configuration.AddressHeaderCollectionElement - - - <Header /> - - - System.ServiceModel.Configuration.IdentityElement - - - System.ServiceModel.Configuration.UserPrincipalNameElement - - - - - - System.ServiceModel.Configuration.ServicePrincipalNameElement - - - - - - System.ServiceModel.Configuration.DnsElement - - - - - - System.ServiceModel.Configuration.RsaElement - - - - - - System.ServiceModel.Configuration.CertificateElement - - - - - - System.ServiceModel.Configuration.CertificateReferenceElement - - - My - - - LocalMachine - - - FindBySubjectDistinguishedName - - - - - - False - - - BasicHttpBinding_LanguageService - - - - - \ No newline at end of file diff --git a/src/L10NSharp.Windows.Forms/Translators/BingTranslator.cs b/src/L10NSharp.Windows.Forms/Translators/BingTranslator.cs deleted file mode 100644 index 579c1dfb..00000000 --- a/src/L10NSharp.Windows.Forms/Translators/BingTranslator.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.ServiceModel; -using System.ServiceModel.Security; -using System.Text; -using System.Threading; -using L10NSharp.Windows.Forms.BingTranslatorService; - -namespace L10NSharp.Windows.Forms.Translators -{ - /// ---------------------------------------------------------------------------------------- - internal class BingTranslator : TranslatorBase - { - // This is my (David Olson) personal application id, acquired from - // Microsoft at http://www.bing.com/developer. - private const string kAppId = "9E98329DE301A6F28025BEFBB66DBD44C1C7265E"; - - /// ------------------------------------------------------------------------------------ - protected LanguageServiceClient m_translator; - - /// ------------------------------------------------------------------------------------ - /// - /// Initializes a new instance of the class. - /// - /// ------------------------------------------------------------------------------------ - public BingTranslator(string srcLangId, string tgtLangId) - { - EndpointAddress endpoint = new EndpointAddress("http://api.microsofttranslator.com/v1/Soap.svc"); - BasicHttpBinding binding = new BasicHttpBinding(); - binding.Name = "BasicHttpBinding_LanguageService"; - binding.CloseTimeout = new TimeSpan(0, 0, 40); - binding.OpenTimeout = new TimeSpan(0, 0, 40); - binding.ReceiveTimeout = new TimeSpan(0, 10, 0); - binding.SendTimeout = new TimeSpan(0, 1, 0); - binding.AllowCookies = false; - binding.BypassProxyOnLocal = false; - binding.MaxBufferSize = 65536; - binding.MaxBufferPoolSize = 524288; - binding.MaxReceivedMessageSize = 65536; - binding.MessageEncoding = WSMessageEncoding.Text; - binding.TextEncoding = Encoding.UTF8; - binding.TransferMode = TransferMode.Buffered; - binding.UseDefaultWebProxy = true; - binding.ReaderQuotas.MaxDepth = 32; - binding.ReaderQuotas.MaxStringContentLength = 8192; - binding.ReaderQuotas.MaxArrayLength = 16384; - binding.ReaderQuotas.MaxBytesPerRead = 4096; - binding.ReaderQuotas.MaxNameTableCharCount = 16384; - binding.Security.Mode = BasicHttpSecurityMode.None; - binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.None; - binding.Security.Transport.ProxyCredentialType = HttpProxyCredentialType.None; - binding.Security.Message.ClientCredentialType = BasicHttpMessageCredentialType.UserName; - binding.Security.Message.AlgorithmSuite = SecurityAlgorithmSuite.Default; - - //I (olson) found that sometimes kAppId is not recognized as a valid application - // Id. When that happens, attempt a few more times then give up. - - //I (hatton) read that you only get a max of 7 per second, so that's a likely reason - //for the failure. On the assumption that this is the problem, I'll add a delay. - int retryCount = 4; - while (m_translator == null && retryCount > 0) - { - try - { - m_translator = new LanguageServiceClient(binding, endpoint); - - var availableLocales = m_translator.GetLanguages(kAppId); - m_srcCultureId = ValidateLocale(availableLocales, srcLangId); - m_tgtCultureId = ValidateLocale(availableLocales, tgtLangId); - break; - } - catch - { - m_translator = null; - Thread.Sleep(300);//we only get 7 per second. - retryCount--; - } - } - } - - /// ------------------------------------------------------------------------------------ - /// - /// Validates the locale. - /// - /// ------------------------------------------------------------------------------------ - private static string ValidateLocale(IEnumerable availableLocales, string locale) - { - if (availableLocales.Where(x => x == locale).FirstOrDefault() != null) - return locale; - - int i = locale.IndexOf('-'); - if (i >= 0) - locale = locale.Substring(0, i); - - return locale; - } - - /// ------------------------------------------------------------------------------------ - /// - /// Internal method for translating the specified text. - /// - /// ------------------------------------------------------------------------------------ - protected override string InternalTranslate(string srcText) - { - return (m_translator == null ? srcText : - m_translator.Translate(kAppId, srcText, m_srcCultureId, m_tgtCultureId)); - } - } -} diff --git a/src/L10NSharp.Windows.Forms/Translators/MicrosoftTranslator.cs b/src/L10NSharp.Windows.Forms/Translators/MicrosoftTranslator.cs new file mode 100644 index 00000000..90a6d7a8 --- /dev/null +++ b/src/L10NSharp.Windows.Forms/Translators/MicrosoftTranslator.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Json; +using System.Text; + +namespace L10NSharp.Windows.Forms.Translators +{ + /// ---------------------------------------------------------------------------------------- + /// + /// Translates text using the Azure AI Translator Text API v3. Unlike + /// , this requires a host app to provision its own Azure + /// Translator resource and supply a subscription key, either by setting + /// in code or the L10NSHARP_TRANSLATOR_KEY environment + /// variable. + /// + /// ---------------------------------------------------------------------------------------- + public class MicrosoftTranslator : TranslatorBase + { + private const string kServiceUrl = "https://api.cognitive.microsofttranslator.com/translate?api-version=3.0"; + private const string kSubscriptionKeyEnvVar = "L10NSHARP_TRANSLATOR_KEY"; + private const string kRegionEnvVar = "L10NSHARP_TRANSLATOR_REGION"; + + /// ------------------------------------------------------------------------------------ + /// + /// The Azure Translator subscription key to use. If not set, falls back to the + /// L10NSHARP_TRANSLATOR_KEY environment variable. + /// + /// ------------------------------------------------------------------------------------ + public static string SubscriptionKey { get; set; } + + /// ------------------------------------------------------------------------------------ + /// + /// The Azure region of the subscription's Translator resource. Only needed for + /// regional (as opposed to "Global") resources. If not set, falls back to the + /// L10NSHARP_TRANSLATOR_REGION environment variable. + /// + /// ------------------------------------------------------------------------------------ + public static string Region { get; set; } + + /// ------------------------------------------------------------------------------------ + /// + /// The subscription key that will actually be used, from either + /// or the L10NSHARP_TRANSLATOR_KEY environment variable. + /// + /// ------------------------------------------------------------------------------------ + public static string EffectiveSubscriptionKey => + string.IsNullOrEmpty(SubscriptionKey) ? Environment.GetEnvironmentVariable(kSubscriptionKeyEnvVar) : SubscriptionKey; + + /// ------------------------------------------------------------------------------------ + /// + /// The region that will actually be used, from either or the + /// L10NSHARP_TRANSLATOR_REGION environment variable. + /// + /// ------------------------------------------------------------------------------------ + public static string EffectiveRegion => + string.IsNullOrEmpty(Region) ? Environment.GetEnvironmentVariable(kRegionEnvVar) : Region; + + /// ------------------------------------------------------------------------------------ + /// + /// True if a subscription key is available from either + /// or the L10NSHARP_TRANSLATOR_KEY environment variable. + /// + /// ------------------------------------------------------------------------------------ + public static bool IsConfigured => !string.IsNullOrEmpty(EffectiveSubscriptionKey); + + /// ------------------------------------------------------------------------------------ + public MicrosoftTranslator(string srcCultureId, string tgtCultureId) + { + m_srcCultureId = srcCultureId; + m_tgtCultureId = tgtCultureId; + } + + /// ------------------------------------------------------------------------------------ + /// + /// Internal method for translating the specified text. + /// + /// ------------------------------------------------------------------------------------ + protected override string InternalTranslate(string srcText) + { + var key = EffectiveSubscriptionKey; + if (string.IsNullOrEmpty(key)) + return srcText; + + try + { + var requestUri = $"{kServiceUrl}&from={m_srcCultureId}&to={m_tgtCultureId}"; + + using var ms = new MemoryStream(); + var requestSer = new DataContractJsonSerializer(typeof(List)); + requestSer.WriteObject(ms, new List { new TranslateRequestItem { Text = srcText } }); + var requestBody = Encoding.UTF8.GetString(ms.ToArray()); + + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", key); + var region = EffectiveRegion; + if (!string.IsNullOrEmpty(region)) + client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Region", region); + + using var content = new StringContent(requestBody, Encoding.UTF8, "application/json"); + using var response = client.PostAsync(requestUri, content).GetAwaiter().GetResult(); + var responseString = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + using var responseStream = new MemoryStream(Encoding.UTF8.GetBytes(responseString)); + var responseSer = new DataContractJsonSerializer(typeof(List)); + var result = responseSer.ReadObject(responseStream) as List; + + return result?.FirstOrDefault()?.Translations?.FirstOrDefault()?.Text ?? string.Empty; + } + catch (Exception) + { + return string.Empty; + } + } + } + + /// ---------------------------------------------------------------------------------------- + [DataContract] + internal class TranslateRequestItem + { + /// ------------------------------------------------------------------------------------ + [DataMember(Name = "Text")] + public string Text { get; set; } + } + + /// ---------------------------------------------------------------------------------------- + [DataContract] + internal class TranslateResponseItem + { + /// ------------------------------------------------------------------------------------ + [DataMember(Name = "translations")] + public List Translations { get; set; } + } + + /// ---------------------------------------------------------------------------------------- + [DataContract] + internal class TranslationItem + { + /// ------------------------------------------------------------------------------------ + [DataMember(Name = "text")] + public string Text { get; set; } + + /// ------------------------------------------------------------------------------------ + [DataMember(Name = "to")] + public string To { get; set; } + } +} diff --git a/src/L10NSharp.Windows.Forms/Translators/GoogleTranslator.cs b/src/L10NSharp.Windows.Forms/Translators/MyMemoryTranslator.cs similarity index 66% rename from src/L10NSharp.Windows.Forms/Translators/GoogleTranslator.cs rename to src/L10NSharp.Windows.Forms/Translators/MyMemoryTranslator.cs index 1f94298f..51ef61fd 100644 --- a/src/L10NSharp.Windows.Forms/Translators/GoogleTranslator.cs +++ b/src/L10NSharp.Windows.Forms/Translators/MyMemoryTranslator.cs @@ -8,14 +8,22 @@ namespace L10NSharp.Windows.Forms.Translators { /// ---------------------------------------------------------------------------------------- - internal class GoogleTranslator : TranslatorBase + /// + /// Translates text using the free, anonymous MyMemory Translation API + /// (https://mymemory.translated.net/doc/spec.php). Requires no signup or key, but is + /// limited to a 5,000 character/day/IP quota, so this is intended only as a fail-safe + /// default for light, best-effort use. Host apps wanting more robust translation can + /// configure instead. + /// + /// ---------------------------------------------------------------------------------------- + internal class MyMemoryTranslator : TranslatorBase { - private const string kServiceUrl = "http://ajax.googleapis.com/ajax/services/language/translate"; + private const string kServiceUrl = "https://api.mymemory.translated.net/get"; /// ------------------------------------------------------------------------------------ - public GoogleTranslator(string srcCultureId, string tgtCultureId) + public MyMemoryTranslator(string srcCultureId, string tgtCultureId) { - // Google can't handle regions. + // MyMemory can't handle regions. int i = srcCultureId.IndexOf('_'); if (i >= 0) srcCultureId = srcCultureId.Substring(0, i); @@ -43,23 +51,23 @@ public GoogleTranslator(string srcCultureId, string tgtCultureId) /// ------------------------------------------------------------------------------------ protected override string InternalTranslate(string srcText) { - var text = HttpUtilityFromMono.UrlPathEncode(srcText); - var ver = HttpUtilityFromMono.UrlEncode("1.0"); + var text = HttpUtilityFromMono.UrlEncode(srcText); var langPair = HttpUtilityFromMono.UrlEncode($"{m_srcCultureId}|{m_tgtCultureId}"); - var encodedRequestUrlFragment = $"?v={ver}&q={text}&langpair={langPair}"; - - var requestUri = kServiceUrl + encodedRequestUrlFragment; + var requestUri = $"{kServiceUrl}?q={text}&langpair={langPair}"; try { using var client = new HttpClient(); var responseString = client.GetStringAsync(requestUri).GetAwaiter().GetResult(); // sync wait - using var ms = new MemoryStream(Encoding.Unicode.GetBytes(responseString)); + using var ms = new MemoryStream(Encoding.UTF8.GetBytes(responseString)); var ser = new DataContractJsonSerializer(typeof(JSONResponse)); var translation = ser.ReadObject(ms) as JSONResponse; - return translation?.responseData?.translatedText ?? string.Empty; + if (translation == null || translation.quotaFinished) + return string.Empty; + + return translation.responseData?.translatedText ?? string.Empty; } catch (Exception) { @@ -77,7 +85,9 @@ internal class JSONResponse /// ------------------------------------------------------------------------------------ public string responseDetails; /// ------------------------------------------------------------------------------------ - public string responseStatus; + public int responseStatus; + /// ------------------------------------------------------------------------------------ + public bool quotaFinished; } /// ---------------------------------------------------------------------------------------- diff --git a/src/L10NSharp.Windows.Forms/Translators/TranslatorBase.cs b/src/L10NSharp.Windows.Forms/Translators/TranslatorBase.cs index a12a6112..8a300996 100644 --- a/src/L10NSharp.Windows.Forms/Translators/TranslatorBase.cs +++ b/src/L10NSharp.Windows.Forms/Translators/TranslatorBase.cs @@ -4,7 +4,7 @@ namespace L10NSharp.Windows.Forms.Translators { /// ---------------------------------------------------------------------------------------- - internal abstract class TranslatorBase + public abstract class TranslatorBase { /// ------------------------------------------------------------------------------------ protected string m_srcCultureId; diff --git a/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs b/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs index 05bebbff..6006b22b 100644 --- a/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs +++ b/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs @@ -20,7 +20,13 @@ public LanguageChoosingDialog(L10NCultureInfo requestedCulture, Icon icon) void Application_Idle(object sender, EventArgs e) { Application.Idle -= Application_Idle; - _model.TranslateStrings(new BingTranslator("en", _model.RequestedCultureTwoLetterISOLanguageName)); + var targetCultureId = _model.RequestedCultureTwoLetterISOLanguageName; + TranslatorBase translator; + if (MicrosoftTranslator.IsConfigured) + translator = new MicrosoftTranslator("en", targetCultureId); + else + translator = new MyMemoryTranslator("en", targetCultureId); + _model.TranslateStrings(translator); _messageLabel.Text = _model.Message; _OKButton.Text = _model.AcceptButtonText; Text = _model.WindowTitle; diff --git a/src/L10NSharp/app.config b/src/L10NSharp/app.config index 026523cf..69f2a12c 100644 --- a/src/L10NSharp/app.config +++ b/src/L10NSharp/app.config @@ -5,22 +5,6 @@
- - - - - - - - - - - - - - - - diff --git a/src/SampleApp/Form1.Designer.cs b/src/SampleApp/Form1.Designer.cs index d6c88fde..8af18d01 100644 --- a/src/SampleApp/Form1.Designer.cs +++ b/src/SampleApp/Form1.Designer.cs @@ -34,6 +34,7 @@ private void InitializeComponent() this.components = new System.ComponentModel.Container(); this.localizationExtender1 = new L10NSharp.Windows.Forms.L10NSharpExtender(this.components); this._getDynamicStringButton = new System.Windows.Forms.Button(); + this._showLanguageChoosingDialogButton = new System.Windows.Forms.Button(); this.label1 = new System.Windows.Forms.Label(); this.uiLanguageComboBox1 = new L10NSharp.Windows.Forms.UIComponents.UILanguageComboBox(); this.columnHeader1 = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); @@ -63,7 +64,20 @@ private void InitializeComponent() this._getDynamicStringButton.Text = "Get Name Dynamically"; this._getDynamicStringButton.UseVisualStyleBackColor = true; this._getDynamicStringButton.Click += new System.EventHandler(this.button1_Click); - // + // + // _showLanguageChoosingDialogButton + // + this.localizationExtender1.SetLocalizableToolTip(this._showLanguageChoosingDialogButton, null); + this.localizationExtender1.SetLocalizationComment(this._showLanguageChoosingDialogButton, null); + this.localizationExtender1.SetLocalizingId(this._showLanguageChoosingDialogButton, "TheSampleForm.showLanguageChoosingDialogButton"); + this._showLanguageChoosingDialogButton.Location = new System.Drawing.Point(184, 161); + this._showLanguageChoosingDialogButton.Name = "_showLanguageChoosingDialogButton"; + this._showLanguageChoosingDialogButton.Size = new System.Drawing.Size(180, 23); + this._showLanguageChoosingDialogButton.TabIndex = 8; + this._showLanguageChoosingDialogButton.Text = "Show Language Chooser"; + this._showLanguageChoosingDialogButton.UseVisualStyleBackColor = true; + this._showLanguageChoosingDialogButton.Click += new System.EventHandler(this.showLanguageChoosingDialogButton_Click); + // // label1 // this.label1.AutoSize = true; @@ -167,6 +181,7 @@ private void InitializeComponent() this.Controls.Add(this.uiLanguageComboBox1); this.Controls.Add(this.label1); this.Controls.Add(this._getDynamicStringButton); + this.Controls.Add(this._showLanguageChoosingDialogButton); this.localizationExtender1.SetLocalizableToolTip(this, null); this.localizationExtender1.SetLocalizationComment(this, null); this.localizationExtender1.SetLocalizingId(this, "Form1.WindowTitle"); @@ -186,6 +201,7 @@ private void InitializeComponent() private L10NSharp.Windows.Forms.L10NSharpExtender localizationExtender1; private System.Windows.Forms.Button _getDynamicStringButton; + private System.Windows.Forms.Button _showLanguageChoosingDialogButton; private System.Windows.Forms.Label label1; private L10NSharp.Windows.Forms.UIComponents.UILanguageComboBox uiLanguageComboBox1; private System.Windows.Forms.ListView listView1; diff --git a/src/SampleApp/Form1.cs b/src/SampleApp/Form1.cs index 5e1ef591..2a343cd2 100644 --- a/src/SampleApp/Form1.cs +++ b/src/SampleApp/Form1.cs @@ -3,6 +3,7 @@ using System.Windows.Forms; using L10NSharp; using L10NSharp.Windows.Forms; +using L10NSharp.Windows.Forms.UIComponents; using SampleApp.Properties; namespace SampleApp @@ -11,6 +12,11 @@ public partial class Form1 : Form { private Label _dynamicLabel; + // "ar" is deliberately excluded: LanguageChoosingDialog doesn't set RightToLeft, so + // Arabic would demo a layout bug rather than the translator. + private static readonly string[] kLanguageChoosingDialogDemoLanguages = { "de", "it", "fr" }; + private static readonly Random kRandom = new Random(); + public Form1() { InitializeComponent(); @@ -62,5 +68,18 @@ private void button1_Click(object sender, EventArgs e) UpdateDynamicLabel(); Controls.Add(_dynamicLabel); } + + // This demonstrates LanguageChoosingDialog's fail-safe, on-the-fly translation of its + // own title/message/OK button text: normally it's only shown when a requested UI + // language has no localization files installed, so this button lets you see it (and + // the translator behind it -- MyMemoryTranslator by default, or MicrosoftTranslator if + // you've set MicrosoftTranslator.SubscriptionKey) on demand. The target language is + // picked randomly each click so you can see a few different translations. + private void showLanguageChoosingDialogButton_Click(object sender, EventArgs e) + { + var cultureId = kLanguageChoosingDialogDemoLanguages[kRandom.Next(kLanguageChoosingDialogDemoLanguages.Length)]; + using var dlg = new LanguageChoosingDialog(L10NCultureInfo.GetCultureInfo(cultureId), Icon); + dlg.ShowDialog(this); + } } } From 97916d41ddfb31b72af6fc9cec098bef1283907c Mon Sep 17 00:00:00 2001 From: tombogle Date: Fri, 4 Sep 2026 15:09:37 -0400 Subject: [PATCH 2/3] Address PR #167 review: connection reuse, UI-thread blocking, provider error leakage Responds to 4 review comments on the BingTranslator replacement: - MicrosoftTranslator/MyMemoryTranslator now share a single static HttpClient (with a 10s timeout) instead of creating a new one per call. MicrosoftTranslator moves its per-call headers (subscription key/region) onto the HttpRequestMessage instead of the shared client's DefaultRequestHeaders, since those are read fresh from mutable static properties on every call and mustn't race across calls. - LanguageChoosingDialog.Application_Idle now runs TranslateStrings on a background thread (Task.Run) and marshals the UI update back via BeginInvoke once done, so a slow/unresponsive translation endpoint can no longer block the dialog's UI thread at all (previously it ran synchronously on Application.Idle with no bound). Guards against the dialog being closed before translation completes. - MyMemoryTranslator now checks responseStatus (!= 200) in addition to the existing quotaFinished check before trusting responseData.translatedText. MyMemory always returns HTTP 200, signaling errors (invalid language pair, quota exhaustion, etc.) only in the body; without this, a provider error/warning string could flow into the dialog as if it were a translation. quotaFinished is now bool? since MyMemory sends JSON null (not false) for it on error responses, which a non-nullable bool can't deserialize. Verified live against real MyMemory success/error responses. Co-Authored-By: Claude Sonnet 5 --- .../Translators/MicrosoftTranslator.cs | 16 +++++++--- .../Translators/MyMemoryTranslator.cs | 13 +++++--- .../UIComponents/LanguageChoosingDialog.cs | 31 ++++++++++++++++--- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/L10NSharp.Windows.Forms/Translators/MicrosoftTranslator.cs b/src/L10NSharp.Windows.Forms/Translators/MicrosoftTranslator.cs index 90a6d7a8..5a1a4b23 100644 --- a/src/L10NSharp.Windows.Forms/Translators/MicrosoftTranslator.cs +++ b/src/L10NSharp.Windows.Forms/Translators/MicrosoftTranslator.cs @@ -24,6 +24,8 @@ public class MicrosoftTranslator : TranslatorBase private const string kSubscriptionKeyEnvVar = "L10NSHARP_TRANSLATOR_KEY"; private const string kRegionEnvVar = "L10NSHARP_TRANSLATOR_REGION"; + private static readonly HttpClient s_client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + /// ------------------------------------------------------------------------------------ /// /// The Azure Translator subscription key to use. If not set, falls back to the @@ -94,14 +96,18 @@ protected override string InternalTranslate(string srcText) requestSer.WriteObject(ms, new List { new TranslateRequestItem { Text = srcText } }); var requestBody = Encoding.UTF8.GetString(ms.ToArray()); - using var client = new HttpClient(); - client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", key); + // Headers go on the request, not the (shared, static) client: DefaultRequestHeaders + // would race with concurrent calls and wouldn't pick up a key/region change between calls. + using var request = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = new StringContent(requestBody, Encoding.UTF8, "application/json") + }; + request.Headers.Add("Ocp-Apim-Subscription-Key", key); var region = EffectiveRegion; if (!string.IsNullOrEmpty(region)) - client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Region", region); + request.Headers.Add("Ocp-Apim-Subscription-Region", region); - using var content = new StringContent(requestBody, Encoding.UTF8, "application/json"); - using var response = client.PostAsync(requestUri, content).GetAwaiter().GetResult(); + using var response = s_client.SendAsync(request).GetAwaiter().GetResult(); var responseString = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); using var responseStream = new MemoryStream(Encoding.UTF8.GetBytes(responseString)); diff --git a/src/L10NSharp.Windows.Forms/Translators/MyMemoryTranslator.cs b/src/L10NSharp.Windows.Forms/Translators/MyMemoryTranslator.cs index 51ef61fd..3de7ccc9 100644 --- a/src/L10NSharp.Windows.Forms/Translators/MyMemoryTranslator.cs +++ b/src/L10NSharp.Windows.Forms/Translators/MyMemoryTranslator.cs @@ -20,6 +20,8 @@ internal class MyMemoryTranslator : TranslatorBase { private const string kServiceUrl = "https://api.mymemory.translated.net/get"; + private static readonly HttpClient s_client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + /// ------------------------------------------------------------------------------------ public MyMemoryTranslator(string srcCultureId, string tgtCultureId) { @@ -57,14 +59,17 @@ protected override string InternalTranslate(string srcText) try { - using var client = new HttpClient(); - var responseString = client.GetStringAsync(requestUri).GetAwaiter().GetResult(); // sync wait + var responseString = s_client.GetStringAsync(requestUri).GetAwaiter().GetResult(); // sync wait using var ms = new MemoryStream(Encoding.UTF8.GetBytes(responseString)); var ser = new DataContractJsonSerializer(typeof(JSONResponse)); var translation = ser.ReadObject(ms) as JSONResponse; - if (translation == null || translation.quotaFinished) + // MyMemory always returns HTTP 200, even for errors (invalid language pair, quota + // exceeded, etc.), signaling failure only via these body fields. On failure, + // responseData.translatedText contains a human-readable provider error/warning + // message, not a translation, so it must not be used. + if (translation == null || translation.responseStatus != 200 || translation.quotaFinished.GetValueOrDefault()) return string.Empty; return translation.responseData?.translatedText ?? string.Empty; @@ -87,7 +92,7 @@ internal class JSONResponse /// ------------------------------------------------------------------------------------ public int responseStatus; /// ------------------------------------------------------------------------------------ - public bool quotaFinished; + public bool? quotaFinished; } /// ---------------------------------------------------------------------------------------- diff --git a/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs b/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs index 6006b22b..e412fade 100644 --- a/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs +++ b/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs @@ -1,5 +1,6 @@ using System; using System.Drawing; +using System.Threading.Tasks; using System.Windows.Forms; using L10NSharp.Windows.Forms.Translators; @@ -26,10 +27,32 @@ void Application_Idle(object sender, EventArgs e) translator = new MicrosoftTranslator("en", targetCultureId); else translator = new MyMemoryTranslator("en", targetCultureId); - _model.TranslateStrings(translator); - _messageLabel.Text = _model.Message; - _OKButton.Text = _model.AcceptButtonText; - Text = _model.WindowTitle; + + // Translation makes a blocking network call (see TranslatorBase.TranslateText). Run it + // on a background thread so a slow or unresponsive endpoint can't freeze this dialog; + // only the (fast) UI update needs to happen on the UI thread, once translation is done. + Task.Run(() => + { + _model.TranslateStrings(translator); + try + { + if (!IsDisposed) + { + BeginInvoke((Action)(() => + { + if (IsDisposed) + return; + _messageLabel.Text = _model.Message; + _OKButton.Text = _model.AcceptButtonText; + Text = _model.WindowTitle; + })); + } + } + catch (ObjectDisposedException) + { + // Dialog was closed before translation finished; nothing to update. + } + }); } public string SelectedLanguage; From 40577b596111ee5f55c5bf853c6835397c14576a Mon Sep 17 00:00:00 2001 From: tombogle Date: Sat, 5 Sep 2026 22:10:26 -0400 Subject: [PATCH 3/3] Clean up stale Bing comments; defer Idle subscription until handle exists - Reword remaining "Bing"/"native language" comments in LanguageChoosingDialogViewModel.cs and its tests to reflect the current generic on-the-fly translator (MyMemoryTranslator), now that BingTranslator is gone. Verified live that MyMemoryTranslator doesn't exhibit either of the old Bing-specific quirks the test comment described (choking on a literal "{0}", or substituting "English" for the target language name). - LanguageChoosingDialog: defer subscribing to Application.Idle until OnHandleCreated instead of doing it in the constructor. Per @imnasnainaec's review, Application.Idle is a process-wide event; if it fired (and the background translation from a previous commit's fix completed) before this dialog's window handle existed, BeginInvoke would throw InvalidOperationException, which nothing catches -- silently leaving the dialog in English. The constructor now just records that translation is needed via a flag; OnHandleCreated hooks Idle once the handle is guaranteed to exist. Co-Authored-By: Claude Sonnet 5 --- .../LanguageChoosingDialogViewModelTests.cs | 5 ++++- .../UIComponents/LanguageChoosingDialog.cs | 17 ++++++++++++++++- .../LanguageChoosingDialogViewModel.cs | 14 +++++++++----- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/L10NSharp.Windows.Forms.Tests/LanguageChoosingDialogViewModelTests.cs b/src/L10NSharp.Windows.Forms.Tests/LanguageChoosingDialogViewModelTests.cs index 36b28d37..adec5b38 100644 --- a/src/L10NSharp.Windows.Forms.Tests/LanguageChoosingDialogViewModelTests.cs +++ b/src/L10NSharp.Windows.Forms.Tests/LanguageChoosingDialogViewModelTests.cs @@ -139,7 +139,10 @@ public void TranslateStrings_RequestedCultureSpanishChokesOnFormatParam_Translat Assert.AreEqual("No localization for Spanish (español)", model.Message); var translator = new TestTranslatorSpanishChokesOnFormatParam(); model.TranslateStrings(translator); - // Note: the test translator mimics Bing's behavior of replacing the English name of the requested language with the word "English" in the translation. + // Note: this fake translator's "chokes on {0}, then swaps in the word 'English'" behavior mimics a quirk of the old, + // now-removed BingTranslator. It's kept as a synthetic worst case for the retry-without-format-param fallback path + // below; the current default translator, MyMemoryTranslator, doesn't exhibit either behavior (verified live: it + // preserves a literal "{0}" through translation, and translates rather than substitutes the language name). Assert.AreEqual("No choke No localization for English (español)", model.Message); Assert.AreEqual("No choke OK", model.AcceptButtonText); Assert.AreEqual("No choke Choose a Language", model.WindowTitle); diff --git a/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs b/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs index e412fade..7003bc0e 100644 --- a/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs +++ b/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialog.cs @@ -9,15 +9,30 @@ namespace L10NSharp.Windows.Forms.UIComponents public partial class LanguageChoosingDialog : Form { private readonly LanguageChoosingDialogViewModel _model; + private bool _translationNeeded; public LanguageChoosingDialog(L10NCultureInfo requestedCulture, Icon icon) { InitializeComponent(); Icon = icon; - _model = new LanguageChoosingDialogViewModel(_messageLabel.Text, _OKButton.Text, Text, requestedCulture, () => { Application.Idle += Application_Idle; } ); + // The callback just records that translation is needed; we wait to hook + // Application.Idle until the handle exists (see OnHandleCreated) so the + // background BeginInvoke in Application_Idle can never run against a + // not-yet-created handle (which would throw InvalidOperationException). + _model = new LanguageChoosingDialogViewModel(_messageLabel.Text, _OKButton.Text, Text, requestedCulture, () => { _translationNeeded = true; } ); _messageLabel.Text = _model.Message; } + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + if (_translationNeeded) + { + _translationNeeded = false; + Application.Idle += Application_Idle; + } + } + void Application_Idle(object sender, EventArgs e) { Application.Idle -= Application_Idle; diff --git a/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialogViewModel.cs b/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialogViewModel.cs index d9d06993..882060c0 100644 --- a/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialogViewModel.cs +++ b/src/L10NSharp.Windows.Forms/UIComponents/LanguageChoosingDialogViewModel.cs @@ -50,15 +50,19 @@ internal void TranslateStrings(TranslatorBase translator) return; if (s.Contains("{0}") && s.Length > 5) // If we just get back "{0}" or "({0})", we won't consider that useful. { - // Bing will presumably have translated the English string into the native language, so now we want - // to display the English name in parentheses. (As a sanity check, we could look to see whether the - // native name is in the string, but there could be situations where it may not be an exact match.) + // The on-the-fly localization will presumably have translated the English + // string into the desired language, so now we want to display the English + // name in parentheses. (As a sanity check, we could look to see whether the + // native name is in the string, but there could be situations where it may + // not be an exact match.) s = string.Format(s, _requestedCulture.EnglishName); } else if (_messageLabelFormat.Contains("{1}")) { - // If we already weeded out the param (because the language names are the same), there's no need to re-try (in case it's slow). - // This is just a fall-back in case there is some rare situation where the translator chokes on the presence of a formatting param in the string. + // If we already weeded out the param (because the language names are the + // same), there's no need to re-try (in case it's slow). This is just a fall- + // back in case there is some rare situation where the translator chokes on + // the presence of a formatting param in the string. s = translator.TranslateText(string.Format(_messageLabelFormat, _requestedCulture.EnglishName, _requestedCulture.NativeName)); }