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/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.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..5a1a4b23
--- /dev/null
+++ b/src/L10NSharp.Windows.Forms/Translators/MicrosoftTranslator.cs
@@ -0,0 +1,156 @@
+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";
+
+ 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
+ /// 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());
+
+ // 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))
+ request.Headers.Add("Ocp-Apim-Subscription-Region", region);
+
+ 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));
+ 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 55%
rename from src/L10NSharp.Windows.Forms/Translators/GoogleTranslator.cs
rename to src/L10NSharp.Windows.Forms/Translators/MyMemoryTranslator.cs
index 1f94298f..3de7ccc9 100644
--- a/src/L10NSharp.Windows.Forms/Translators/GoogleTranslator.cs
+++ b/src/L10NSharp.Windows.Forms/Translators/MyMemoryTranslator.cs
@@ -8,14 +8,24 @@
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";
+
+ private static readonly HttpClient s_client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
/// ------------------------------------------------------------------------------------
- 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 +53,26 @@ 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
+ var responseString = s_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;
+ // 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;
}
catch (Exception)
{
@@ -77,7 +90,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..7003bc0e 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;
@@ -8,22 +9,65 @@ 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;
- _model.TranslateStrings(new BingTranslator("en", _model.RequestedCultureTwoLetterISOLanguageName));
- _messageLabel.Text = _model.Message;
- _OKButton.Text = _model.AcceptButtonText;
- Text = _model.WindowTitle;
+ var targetCultureId = _model.RequestedCultureTwoLetterISOLanguageName;
+ TranslatorBase translator;
+ if (MicrosoftTranslator.IsConfigured)
+ translator = new MicrosoftTranslator("en", targetCultureId);
+ else
+ translator = new MyMemoryTranslator("en", targetCultureId);
+
+ // 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;
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));
}
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);
+ }
}
}