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); + } } }