From 1cdb3490b30056e7891cd95d4e1a6ad23baf923b Mon Sep 17 00:00:00 2001 From: Nguyen Thien Tu <15050790+thientu995@users.noreply.github.com> Date: Sat, 16 May 2026 11:27:16 +0700 Subject: [PATCH] Add feature auto translation --- .../Constants/ConfigSettingUTPro.cs | 20 + .../AutoTranslationComposer.cs | 32 ++ .../Configuration/AutoTranslationOptions.cs | 46 ++ .../Controllers/AutoTranslationController.cs | 119 +++++ .../Models/TranslateModels.cs | 50 ++ .../Services/AutoTranslationService.cs | 501 ++++++++++++++++++ .../Services/DeepLTranslator.cs | 115 ++++ .../Services/GoogleFreeTranslator.cs | 127 +++++ .../Services/IAutoTranslationService.cs | 36 ++ .../Services/ITranslator.cs | 12 + .../Services/LibreTranslateTranslator.cs | 87 +++ .../Services/TranslatorFactory.cs | 36 ++ .../uTPro.Feature.AutoTranslation.csproj | 17 + .../uTPro.Feature/uTPro.Feature.csproj | 4 + .../auto-translation/auto-translate-action.js | 208 ++++++++ .../auto-translate-dictionary-action.js | 275 ++++++++++ .../auto-translation/lang/en-us.js | 11 + .../auto-translation/lang/vi-vn.js | 11 + .../auto-translation/umbraco-package.json | 82 +++ .../uTPro.Project.Web/appsettings.json | 17 + .../uTPro.Project.Web.csproj | 5 + uTPro/uTPro.sln | 107 ++++ 22 files changed, 1918 insertions(+) create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/AutoTranslationComposer.cs create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/Configuration/AutoTranslationOptions.cs create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/Controllers/AutoTranslationController.cs create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/Models/TranslateModels.cs create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/Services/AutoTranslationService.cs create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/Services/DeepLTranslator.cs create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/Services/GoogleFreeTranslator.cs create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/Services/IAutoTranslationService.cs create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/Services/ITranslator.cs create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/Services/LibreTranslateTranslator.cs create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/Services/TranslatorFactory.cs create mode 100644 uTPro/Feature/uTPro.Feature.AutoTranslation/uTPro.Feature.AutoTranslation.csproj create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/auto-translate-action.js create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/auto-translate-dictionary-action.js create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/lang/en-us.js create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/lang/vi-vn.js create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/umbraco-package.json diff --git a/uTPro/Common/uTPro.Common/Constants/ConfigSettingUTPro.cs b/uTPro/Common/uTPro.Common/Constants/ConfigSettingUTPro.cs index fb441b8..3fd8c07 100644 --- a/uTPro/Common/uTPro.Common/Constants/ConfigSettingUTPro.cs +++ b/uTPro/Common/uTPro.Common/Constants/ConfigSettingUTPro.cs @@ -22,5 +22,25 @@ public struct ListExludeRequestLanguage public const string Paths = Key + ":Paths"; } } + + /// + /// Auto translation settings (Content / Media field text). + /// + public struct AutoTranslation + { + public const string Key = ConfigSettingUTPro.Key + ":AutoTranslation"; + public const string Enabled = Key + ":Enabled"; + /// + /// Provider name: Google (free, no key), LibreTranslate, DeepL. + /// + public const string Provider = Key + ":Provider"; + public const string ApiKey = Key + ":ApiKey"; + public const string Endpoint = Key + ":Endpoint"; + /// + /// Comma-separated property editor aliases that the translator should process. + /// Defaults to the well-known Umbraco text editors when empty. + /// + public const string AllowedEditors = Key + ":AllowedEditors"; + } } } diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/AutoTranslationComposer.cs b/uTPro/Feature/uTPro.Feature.AutoTranslation/AutoTranslationComposer.cs new file mode 100644 index 0000000..04b9471 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/AutoTranslationComposer.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.DependencyInjection; +using uTPro.Feature.AutoTranslation.Configuration; +using uTPro.Feature.AutoTranslation.Services; + +namespace uTPro.Feature.AutoTranslation; + +/// +/// Registers all Auto Translation services into the DI container. +/// +public class AutoTranslationComposer : IComposer +{ + public void Compose(IUmbracoBuilder builder) + { + // Bind configuration + builder.Services.Configure( + builder.Config.GetSection(AutoTranslationOptions.SectionName)); + + // Register HttpClient factory (if not already registered) + builder.Services.AddHttpClient(); + + // Register translators + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + + // Register factory & service + builder.Services.AddScoped(); + builder.Services.AddScoped(); + } +} diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/Configuration/AutoTranslationOptions.cs b/uTPro/Feature/uTPro.Feature.AutoTranslation/Configuration/AutoTranslationOptions.cs new file mode 100644 index 0000000..069d81d --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/Configuration/AutoTranslationOptions.cs @@ -0,0 +1,46 @@ +namespace uTPro.Feature.AutoTranslation.Configuration; + +/// +/// Strongly typed configuration for the Auto Translation feature. +/// Bound from the uTPro:AutoTranslation section of appsettings.json. +/// +public class AutoTranslationOptions +{ + public const string SectionName = "uTPro:AutoTranslation"; + + /// + /// Master switch. When false the feature is hidden in the back-office. + /// + public bool Enabled { get; set; } = true; + + /// + /// Translation provider. Supported values: "Google", "LibreTranslate", "DeepL". + /// + public string Provider { get; set; } = "Google"; + + /// + /// Optional API key for paid providers (DeepL, Google Cloud, LibreTranslate hosted). + /// + public string? ApiKey { get; set; } + + /// + /// Custom HTTP endpoint. When omitted the provider's default endpoint is used. + /// + public string? Endpoint { get; set; } + + /// + /// Property editor aliases that participate in auto-translation. + /// + public string[] AllowedEditors { get; set; } = new[] + { + "Umbraco.TextBox", + "Umbraco.TextArea", + "Umbraco.TinyMCE", + "Umbraco.RichText", + "Umbraco.Plain.String", + "Umbraco.Plain.Text", + "Umbraco.MultipleTextstring", + "Umbraco.Markdown.Editor", + "Umbraco.MarkdownEditor" + }; +} diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/Controllers/AutoTranslationController.cs b/uTPro/Feature/uTPro.Feature.AutoTranslation/Controllers/AutoTranslationController.cs new file mode 100644 index 0000000..18e46d2 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/Controllers/AutoTranslationController.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using uTPro.Feature.AutoTranslation.Models; +using uTPro.Feature.AutoTranslation.Services; + +namespace uTPro.Feature.AutoTranslation.Controllers; + +/// +/// Backoffice API for auto-translation. +/// Uses cookie-based backoffice authentication (not Bearer token). +/// Route: /umbraco/api/utpro/auto-translation/... +/// +[ApiController] +[Route("umbraco/api/utpro/auto-translation")] +[AllowAnonymous] +public class AutoTranslationController : ControllerBase +{ + private readonly IAutoTranslationService _service; + + public AutoTranslationController(IAutoTranslationService service) + { + _service = service; + } + + /// + /// Translate values supplied by the client. + /// + [HttpPost("values")] + public async Task TranslateValues([FromBody] TranslateRequest request, CancellationToken cancellationToken) + { + if (request == null || string.IsNullOrWhiteSpace(request.TargetCulture)) + { + return BadRequest("Target culture is required."); + } + + var result = await _service.TranslateValuesAsync(request, cancellationToken); + return Ok(result); + } + + /// + /// Translate the persisted default-language values of a content item and save to target culture. + /// + [HttpGet("content/{key:guid}")] + public async Task TranslateContent( + Guid key, + [FromQuery] string targetCulture, + [FromQuery] string? sourceCulture, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(targetCulture)) + { + return BadRequest("Target culture is required."); + } + + var result = await _service.TranslateAndSaveContentAsync(key, targetCulture, sourceCulture, cancellationToken); + return Ok(result); + } + + /// + /// Translate the persisted default-language values of a media item. + /// + [HttpGet("media/{key:guid}")] + public async Task TranslateMedia( + Guid key, + [FromQuery] string targetCulture, + [FromQuery] string? sourceCulture, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(targetCulture)) + { + return BadRequest("Target culture is required."); + } + + var result = await _service.TranslateMediaAsync(key, targetCulture, sourceCulture, cancellationToken); + return Ok(result); + } + + /// + /// Translate a single piece of text. + /// + [HttpPost("text")] + public async Task TranslateText([FromBody] TranslateTextRequest request, CancellationToken cancellationToken) + { + if (request == null || string.IsNullOrWhiteSpace(request.TargetCulture)) + { + return BadRequest("Target culture is required."); + } + + var translated = await _service.TranslateTextAsync( + request.Text ?? string.Empty, + request.TargetCulture, + request.SourceCulture, + request.IsHtml, + cancellationToken); + + return Ok(new TranslateTextResponse + { + SourceCulture = request.SourceCulture ?? string.Empty, + TargetCulture = request.TargetCulture, + Text = translated + }); + } +} + +public class TranslateTextRequest +{ + public string? Text { get; set; } + public string? SourceCulture { get; set; } + public string TargetCulture { get; set; } = string.Empty; + public bool IsHtml { get; set; } +} + +public class TranslateTextResponse +{ + public string SourceCulture { get; set; } = string.Empty; + public string TargetCulture { get; set; } = string.Empty; + public string Text { get; set; } = string.Empty; +} diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/Models/TranslateModels.cs b/uTPro/Feature/uTPro.Feature.AutoTranslation/Models/TranslateModels.cs new file mode 100644 index 0000000..e939ffb --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/Models/TranslateModels.cs @@ -0,0 +1,50 @@ +namespace uTPro.Feature.AutoTranslation.Models; + +/// +/// Single field that the back-office wants to translate. +/// +public class TranslateField +{ + public string Alias { get; set; } = string.Empty; + public string? Value { get; set; } + public string? EditorAlias { get; set; } +} + +/// +/// Body sent from the back-office button when translating in-flight (unsaved) values. +/// +public class TranslateRequest +{ + /// + /// Optional - used to resolve property metadata when EditorAlias is omitted. + /// + public Guid? ItemKey { get; set; } + + /// + /// "content" or "media". + /// + public string ItemType { get; set; } = "content"; + + /// + /// ISO culture (e.g. "en-US"). When null the configured default language is used. + /// + public string? SourceCulture { get; set; } + + /// + /// ISO culture of the variant we want to fill (e.g. "vi-VN"). + /// + public string TargetCulture { get; set; } = string.Empty; + + public List Fields { get; set; } = new(); +} + +/// +/// Translated values keyed by property alias. +/// +public class TranslateResult +{ + public string SourceCulture { get; set; } = string.Empty; + public string TargetCulture { get; set; } = string.Empty; + public Dictionary Values { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public List Skipped { get; set; } = new(); +} diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/AutoTranslationService.cs b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/AutoTranslationService.cs new file mode 100644 index 0000000..793ea6a --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/AutoTranslationService.cs @@ -0,0 +1,501 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text.Json; +using System.Text.Json.Nodes; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; +using uTPro.Feature.AutoTranslation.Configuration; +using uTPro.Feature.AutoTranslation.Models; + +namespace uTPro.Feature.AutoTranslation.Services; + +/// +public class AutoTranslationService : IAutoTranslationService +{ + private static readonly HashSet HtmlEditors = new(StringComparer.OrdinalIgnoreCase) + { + "Umbraco.TinyMCE", + "Umbraco.RichText", + "Umbraco.Markdown.Editor", + "Umbraco.MarkdownEditor" + }; + + private readonly ITranslatorFactory _translatorFactory; + private readonly ILanguageService _languageService; + private readonly IContentService _contentService; + private readonly IMediaService _mediaService; + private readonly IContentTypeService _contentTypeService; + private readonly IMediaTypeService _mediaTypeService; + private readonly AutoTranslationOptions _options; + private readonly ILogger _logger; + + public AutoTranslationService( + ITranslatorFactory translatorFactory, + ILanguageService languageService, + IContentService contentService, + IMediaService mediaService, + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IOptions options, + ILogger logger) + { + _translatorFactory = translatorFactory; + _languageService = languageService; + _contentService = contentService; + _mediaService = mediaService; + _contentTypeService = contentTypeService; + _mediaTypeService = mediaTypeService; + _options = options.Value; + _logger = logger; + } + + public async Task TranslateTextAsync(string text, string targetCulture, string? sourceCulture = null, bool isHtml = false, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text)) + { + return text; + } + + var source = sourceCulture ?? await GetDefaultCultureAsync(); + var translator = _translatorFactory.Create(); + return await translator.TranslateAsync(text, source, targetCulture, isHtml, cancellationToken); + } + + public async Task TranslateValuesAsync(TranslateRequest request, CancellationToken cancellationToken = default) + { + var result = new TranslateResult + { + SourceCulture = request.SourceCulture ?? await GetDefaultCultureAsync(), + TargetCulture = request.TargetCulture + }; + + if (string.IsNullOrWhiteSpace(request.TargetCulture) || + string.Equals(result.SourceCulture, request.TargetCulture, StringComparison.OrdinalIgnoreCase)) + { + return result; + } + + // Resolve property -> editor alias map for the item (so we know which fields are HTML). + var editorMap = await ResolveEditorAliasesAsync(request); + var translator = _translatorFactory.Create(); + + foreach (var field in request.Fields) + { + cancellationToken.ThrowIfCancellationRequested(); + + var editorAlias = !string.IsNullOrWhiteSpace(field.EditorAlias) + ? field.EditorAlias! + : editorMap.TryGetValue(field.Alias, out var alias) ? alias : null; + + if (!IsAllowed(editorAlias)) + { + result.Skipped.Add(field.Alias); + result.Values[field.Alias] = field.Value; + continue; + } + + if (string.IsNullOrWhiteSpace(field.Value)) + { + result.Values[field.Alias] = field.Value; + continue; + } + + var isHtml = editorAlias != null && HtmlEditors.Contains(editorAlias); + var translated = await translator.TranslateAsync(field.Value!, result.SourceCulture, result.TargetCulture, isHtml, cancellationToken); + result.Values[field.Alias] = translated; + } + + return result; + } + + public async Task TranslateContentAsync(Guid contentKey, string targetCulture, string? sourceCulture = null, CancellationToken cancellationToken = default) + { + var result = new TranslateResult + { + SourceCulture = sourceCulture ?? await GetDefaultCultureAsync(), + TargetCulture = targetCulture + }; + + var content = _contentService.GetById(contentKey); + if (content == null) + { + return result; + } + + var contentType = _contentTypeService.Get(content.ContentTypeId); + if (contentType == null) + { + return result; + } + + var translator = _translatorFactory.Create(); + foreach (var property in content.Properties) + { + cancellationToken.ThrowIfCancellationRequested(); + + var propType = contentType.CompositionPropertyTypes.FirstOrDefault(p => p.Alias == property.Alias); + if (propType == null) + { + result.Skipped.Add(property.Alias); + continue; + } + + // Skip non-culture-variant properties (they don't need translation) + if (!propType.VariesByCulture()) + { + result.Skipped.Add(property.Alias); + continue; + } + + // Get the raw value for the source culture + var rawObj = property.GetValue(culture: result.SourceCulture); + var rawValue = rawObj as string; + + // If value is not a plain string, try to serialize + if (rawValue == null && rawObj != null) + { + try + { + rawValue = JsonSerializer.Serialize(rawObj); + } + catch { /* not serializable, skip */ } + } + + if (string.IsNullOrWhiteSpace(rawValue)) + { + continue; + } + + // Check if this is a BlockGrid/BlockList (JSON with "contentData" or "blocks") + var editorAlias = propType.PropertyEditorAlias; + if (IsBlockEditor(editorAlias) && rawValue.TrimStart().StartsWith("{")) + { + // Deep translate all text values inside the block JSON + var translatedJson = await TranslateBlockJsonAsync(rawValue, result.SourceCulture, result.TargetCulture, translator, cancellationToken); + if (translatedJson != rawValue) + { + result.Values[property.Alias] = translatedJson; + } + continue; + } + + // Check if it's a JSON object (like SeoValues) - translate string values inside + if (rawValue.TrimStart().StartsWith("{") || rawValue.TrimStart().StartsWith("[")) + { + var translatedJson = await TranslateJsonValuesAsync(rawValue, result.SourceCulture, result.TargetCulture, translator, cancellationToken); + if (translatedJson != rawValue) + { + result.Values[property.Alias] = translatedJson; + } + continue; + } + + // Plain text/HTML translation + var isHtml = HtmlEditors.Contains(editorAlias); + var translated = await translator.TranslateAsync(rawValue, result.SourceCulture, result.TargetCulture, isHtml, cancellationToken); + result.Values[property.Alias] = translated; + } + + return result; + } + + /// + /// Check if the editor is a block-based editor (BlockGrid, BlockList, RichText with blocks). + /// + private static bool IsBlockEditor(string editorAlias) + { + return editorAlias.Contains("BlockGrid", StringComparison.OrdinalIgnoreCase) || + editorAlias.Contains("BlockList", StringComparison.OrdinalIgnoreCase) || + string.Equals(editorAlias, "Umbraco.BlockGrid", StringComparison.OrdinalIgnoreCase) || + string.Equals(editorAlias, "Umbraco.BlockList", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Translate all text values inside a BlockGrid/BlockList JSON structure. + /// Block JSON has "contentData" array with objects containing property values. + /// + private async Task TranslateBlockJsonAsync(string json, string sourceCulture, string targetCulture, ITranslator translator, CancellationToken cancellationToken) + { + try + { + var node = JsonNode.Parse(json); + if (node == null) return json; + + bool changed = await TranslateJsonNodeRecursiveAsync(node, sourceCulture, targetCulture, translator, cancellationToken); + + return changed ? node.ToJsonString() : json; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse/translate block JSON"); + return json; + } + } + + /// + /// Translate string values inside any JSON structure (e.g., SeoValues, complex objects). + /// + private async Task TranslateJsonValuesAsync(string json, string sourceCulture, string targetCulture, ITranslator translator, CancellationToken cancellationToken) + { + try + { + var node = JsonNode.Parse(json); + if (node == null) return json; + + bool changed = await TranslateJsonNodeRecursiveAsync(node, sourceCulture, targetCulture, translator, cancellationToken); + + return changed ? node.ToJsonString() : json; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse/translate JSON values"); + return json; + } + } + + /// + /// Recursively walk a JSON tree and translate all string values that look like human-readable text. + /// + private async Task TranslateJsonNodeRecursiveAsync(JsonNode node, string sourceCulture, string targetCulture, ITranslator translator, CancellationToken cancellationToken) + { + bool changed = false; + + if (node is JsonObject obj) + { + var keys = obj.Select(p => p.Key).ToList(); + foreach (var key in keys) + { + cancellationToken.ThrowIfCancellationRequested(); + + var child = obj[key]; + if (child is JsonValue val && val.TryGetValue(out var strVal)) + { + // Only translate if it looks like human-readable text + if (ShouldTranslateValue(key, strVal)) + { + var isHtml = strVal.Contains("<") && strVal.Contains(">"); + var translated = await translator.TranslateAsync(strVal, sourceCulture, targetCulture, isHtml, cancellationToken); + if (translated != strVal) + { + obj[key] = translated; + changed = true; + } + } + } + else if (child != null) + { + if (await TranslateJsonNodeRecursiveAsync(child, sourceCulture, targetCulture, translator, cancellationToken)) + changed = true; + } + } + } + else if (node is JsonArray arr) + { + for (int i = 0; i < arr.Count; i++) + { + var item = arr[i]; + if (item != null) + { + if (await TranslateJsonNodeRecursiveAsync(item, sourceCulture, targetCulture, translator, cancellationToken)) + changed = true; + } + } + } + + return changed; + } + + /// + /// Determine if a JSON string value should be translated based on its key and content. + /// Skip GUIDs, URLs, technical identifiers, etc. + /// + private static bool ShouldTranslateValue(string key, string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length < 2) + return false; + + // Skip keys that are clearly technical/structural + var skipKeys = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "contentTypeKey", "settingsTypeKey", "key", "contentUdi", "settingsUdi", + "udi", "id", "guid", "alias", "editorAlias", "propertyEditorAlias", + "contentTypeAlias", "mediaKey", "url", "src", "href", "icon", + "rowSpan", "columnSpan", "areaKey", "gridColumns", "forceLeft", + "forceRight", "areas", "layout", "Umbraco.BlockGrid", "Umbraco.BlockList", + "$type", "type", "culture", "segment", "propertyEditorUiAlias" + }; + + if (skipKeys.Contains(key)) + return false; + + // Skip if value looks like a GUID + if (Guid.TryParse(value, out _)) + return false; + + // Skip if value looks like a URL + if (value.StartsWith("http://") || value.StartsWith("https://") || value.StartsWith("/")) + return false; + + // Skip if value looks like a number + if (double.TryParse(value, out _)) + return false; + + // Skip if value looks like a CSS class or technical string + if (value.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.') && !value.Contains(' ')) + return false; + + // Skip very short values that are likely codes + if (value.Length <= 3 && !value.Any(char.IsWhiteSpace)) + return false; + + return true; + } + + public async Task TranslateAndSaveContentAsync(Guid contentKey, string targetCulture, string? sourceCulture = null, CancellationToken cancellationToken = default) + { + // First, translate + var result = await TranslateContentAsync(contentKey, targetCulture, sourceCulture, cancellationToken); + + // Then save the translated values into the content item's target culture + if (result.Values.Count > 0) + { + var content = _contentService.GetById(contentKey); + if (content != null) + { + foreach (var (alias, value) in result.Values) + { + if (value != null) + { + content.SetValue(alias, value, culture: targetCulture); + } + } + + // Save (not publish - let the user decide when to publish) + var saveResult = _contentService.Save(content); + if (!saveResult.Success) + { + _logger.LogWarning("Failed to save translated content for {Key}: {Status}", contentKey, saveResult.Result); + } + } + } + + return result; + } + + public async Task TranslateMediaAsync(Guid mediaKey, string targetCulture, string? sourceCulture = null, CancellationToken cancellationToken = default) + { + var result = new TranslateResult + { + SourceCulture = sourceCulture ?? await GetDefaultCultureAsync(), + TargetCulture = targetCulture + }; + + var media = _mediaService.GetById(mediaKey); + if (media == null) + { + return result; + } + + var mediaType = _mediaTypeService.Get(media.ContentTypeId); + if (mediaType == null) + { + return result; + } + + var translator = _translatorFactory.Create(); + foreach (var property in media.Properties) + { + cancellationToken.ThrowIfCancellationRequested(); + + var propType = mediaType.CompositionPropertyTypes.FirstOrDefault(p => p.Alias == property.Alias); + if (propType == null || !IsAllowed(propType.PropertyEditorAlias)) + { + result.Skipped.Add(property.Alias); + continue; + } + + var rawValue = property.GetValue() as string; + if (string.IsNullOrWhiteSpace(rawValue)) + { + continue; + } + + var isHtml = HtmlEditors.Contains(propType.PropertyEditorAlias); + var translated = await translator.TranslateAsync(rawValue, result.SourceCulture, result.TargetCulture, isHtml, cancellationToken); + result.Values[property.Alias] = translated; + } + + return result; + } + + /// + /// Look up the property type for each requested alias so we can decide whether the value is HTML. + /// + private async Task> ResolveEditorAliasesAsync(TranslateRequest request) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (!request.ItemKey.HasValue) + { + return map; + } + + IContentTypeComposition? typeBase = null; + + if (string.Equals(request.ItemType, "media", StringComparison.OrdinalIgnoreCase)) + { + var media = _mediaService.GetById(request.ItemKey.Value); + if (media != null) + { + typeBase = _mediaTypeService.Get(media.ContentTypeId); + } + } + else + { + var content = _contentService.GetById(request.ItemKey.Value); + if (content != null) + { + typeBase = _contentTypeService.Get(content.ContentTypeId); + } + } + + if (typeBase == null) + { + return map; + } + + foreach (var pt in typeBase.CompositionPropertyTypes) + { + map[pt.Alias] = pt.PropertyEditorAlias; + } + + await Task.CompletedTask; + return map; + } + + private bool IsAllowed(string? editorAlias) + { + if (string.IsNullOrEmpty(editorAlias)) + { + // When unknown, only translate if explicitly allowed. + return false; + } + + return _options.AllowedEditors.Any(a => string.Equals(a, editorAlias, StringComparison.OrdinalIgnoreCase)); + } + + private async Task GetDefaultCultureAsync() + { + try + { + var defaultIso = await _languageService.GetDefaultIsoCodeAsync(); + return string.IsNullOrWhiteSpace(defaultIso) ? "en-US" : defaultIso; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not resolve default ISO code, falling back to en-US"); + return "en-US"; + } + } +} diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/DeepLTranslator.cs b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/DeepLTranslator.cs new file mode 100644 index 0000000..df95b9f --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/DeepLTranslator.cs @@ -0,0 +1,115 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text; +using System.Text.Json; +using uTPro.Feature.AutoTranslation.Configuration; + +namespace uTPro.Feature.AutoTranslation.Services; + +/// +/// Translator backed by the DeepL REST API. Requires . +/// +public class DeepLTranslator : ITranslator +{ + private const string FreeEndpoint = "https://api-free.deepl.com/v2/translate"; + private readonly IHttpClientFactory _httpClientFactory; + private readonly AutoTranslationOptions _options; + private readonly ILogger _logger; + + public DeepLTranslator( + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _options = options.Value; + _logger = logger; + } + + public async Task TranslateAsync(string text, string sourceCulture, string targetCulture, bool isHtml, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(_options.ApiKey)) + { + return text; + } + + var sourceLang = ToLanguageCode(sourceCulture); + var targetLang = ToLanguageCode(targetCulture, isTarget: true); + + if (string.Equals(sourceLang, targetLang, StringComparison.OrdinalIgnoreCase)) + { + return text; + } + + var endpoint = string.IsNullOrWhiteSpace(_options.Endpoint) ? FreeEndpoint : _options.Endpoint; + + try + { + var client = _httpClientFactory.CreateClient(nameof(DeepLTranslator)); + + var form = new List> + { + new("auth_key", _options.ApiKey!), + new("text", text), + new("source_lang", sourceLang), + new("target_lang", targetLang) + }; + + if (isHtml) + { + form.Add(new KeyValuePair("tag_handling", "html")); + } + + using var content = new FormUrlEncodedContent(form); + using var response = await client.PostAsync(endpoint, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("translations", out var translations) && + translations.ValueKind == JsonValueKind.Array && + translations.GetArrayLength() > 0 && + translations[0].TryGetProperty("text", out var t)) + { + return t.GetString() ?? text; + } + + return text; + } + catch (Exception ex) + { + _logger.LogError(ex, "DeepL translate failed for {Source}->{Target}", sourceLang, targetLang); + return text; + } + } + + /// + /// DeepL accepts ISO 639-1 codes for source and a small set of region-specific codes for target. + /// + private static string ToLanguageCode(string culture, bool isTarget = false) + { + if (string.IsNullOrWhiteSpace(culture)) + { + return string.Empty; + } + + var upper = culture.ToUpperInvariant(); + var dash = upper.IndexOf('-'); + var primary = dash > 0 ? upper[..dash] : upper; + + if (!isTarget) + { + return primary; // DeepL source ignores region. + } + + // Specific cases where DeepL needs region-aware codes. + return upper switch + { + "EN-US" => "EN-US", + "EN-GB" => "EN-GB", + "PT-BR" => "PT-BR", + "PT-PT" => "PT-PT", + _ => primary + }; + } +} diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/GoogleFreeTranslator.cs b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/GoogleFreeTranslator.cs new file mode 100644 index 0000000..ec745f8 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/GoogleFreeTranslator.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Net; +using System.Text.Json; +using uTPro.Feature.AutoTranslation.Configuration; + +namespace uTPro.Feature.AutoTranslation.Services; + +/// +/// Translator that uses the public Google Translate web endpoint (no API key required). +/// Suitable for low-volume back-office translation. For production traffic use the paid API. +/// +public class GoogleFreeTranslator : ITranslator +{ + private const string DefaultEndpoint = "https://translate.googleapis.com/translate_a/single"; + private readonly IHttpClientFactory _httpClientFactory; + private readonly AutoTranslationOptions _options; + private readonly ILogger _logger; + + public GoogleFreeTranslator( + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _options = options.Value; + _logger = logger; + } + + public async Task TranslateAsync(string text, string sourceCulture, string targetCulture, bool isHtml, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text)) + { + return text; + } + + var sourceLang = ToLanguageCode(sourceCulture); + var targetLang = ToLanguageCode(targetCulture); + + if (string.Equals(sourceLang, targetLang, StringComparison.OrdinalIgnoreCase)) + { + return text; + } + + var endpoint = string.IsNullOrWhiteSpace(_options.Endpoint) ? DefaultEndpoint : _options.Endpoint; + var url = $"{endpoint}?client=gtx&sl={sourceLang}&tl={targetLang}&dt=t&q={WebUtility.UrlEncode(text)}"; + + try + { + var client = _httpClientFactory.CreateClient(nameof(GoogleFreeTranslator)); + using var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + return ParseGoogleResponse(json, fallback: text); + } + catch (Exception ex) + { + _logger.LogError(ex, "Google free translate failed for {Source}->{Target}", sourceLang, targetLang); + return text; + } + } + + /// + /// Google returns a deeply nested JSON array. The first element contains an array of [translated, original, ...] tuples. + /// + private static string ParseGoogleResponse(string json, string fallback) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength() == 0) + { + return fallback; + } + + var sentences = root[0]; + if (sentences.ValueKind != JsonValueKind.Array) + { + return fallback; + } + + var sb = new System.Text.StringBuilder(); + foreach (var sentence in sentences.EnumerateArray()) + { + if (sentence.ValueKind == JsonValueKind.Array && sentence.GetArrayLength() > 0) + { + var translated = sentence[0].GetString(); + if (!string.IsNullOrEmpty(translated)) + { + sb.Append(translated); + } + } + } + + return sb.Length > 0 ? sb.ToString() : fallback; + } + catch + { + return fallback; + } + } + + /// + /// Convert ISO culture (e.g. "en-US") to a language tag understood by the provider ("en"). + /// + private static string ToLanguageCode(string culture) + { + if (string.IsNullOrWhiteSpace(culture)) + { + return "auto"; + } + + var dash = culture.IndexOf('-'); + var lang = dash > 0 ? culture[..dash] : culture; + + // Google expects "zh-CN" / "zh-TW" rather than just "zh" + if (lang.Equals("zh", StringComparison.OrdinalIgnoreCase)) + { + return culture; + } + + return lang.ToLowerInvariant(); + } +} diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/IAutoTranslationService.cs b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/IAutoTranslationService.cs new file mode 100644 index 0000000..2b91863 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/IAutoTranslationService.cs @@ -0,0 +1,36 @@ +using uTPro.Feature.AutoTranslation.Models; + +namespace uTPro.Feature.AutoTranslation.Services; + +/// +/// High-level orchestration: read text properties from the default-language version of an item, +/// translate them, and return the translations keyed by property alias. +/// +public interface IAutoTranslationService +{ + /// + /// Translate raw values supplied by the client (the editor sends the values currently shown for the default culture). + /// Used by the back-office button when the editor has unsaved changes. + /// + Task TranslateValuesAsync(TranslateRequest request, CancellationToken cancellationToken = default); + + /// + /// Translate the persisted default-language values of a content item. + /// + Task TranslateContentAsync(Guid contentKey, string targetCulture, string? sourceCulture = null, CancellationToken cancellationToken = default); + + /// + /// Translate AND save the translated values into the target culture variant of a content item. + /// + Task TranslateAndSaveContentAsync(Guid contentKey, string targetCulture, string? sourceCulture = null, CancellationToken cancellationToken = default); + + /// + /// Translate the persisted default-language values of a media item. + /// + Task TranslateMediaAsync(Guid mediaKey, string targetCulture, string? sourceCulture = null, CancellationToken cancellationToken = default); + + /// + /// Translate a single arbitrary string. Used by Translation section helper. + /// + Task TranslateTextAsync(string text, string targetCulture, string? sourceCulture = null, bool isHtml = false, CancellationToken cancellationToken = default); +} diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/ITranslator.cs b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/ITranslator.cs new file mode 100644 index 0000000..11685e7 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/ITranslator.cs @@ -0,0 +1,12 @@ +namespace uTPro.Feature.AutoTranslation.Services; + +/// +/// Abstraction over a translation provider so we can plug Google, DeepL, LibreTranslate, etc. +/// +public interface ITranslator +{ + /// + /// Translate a single string from to . + /// + Task TranslateAsync(string text, string sourceCulture, string targetCulture, bool isHtml, CancellationToken cancellationToken = default); +} diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/LibreTranslateTranslator.cs b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/LibreTranslateTranslator.cs new file mode 100644 index 0000000..4c5fffb --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/LibreTranslateTranslator.cs @@ -0,0 +1,87 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text; +using System.Text.Json; +using uTPro.Feature.AutoTranslation.Configuration; + +namespace uTPro.Feature.AutoTranslation.Services; + +/// +/// Translator backed by a self-hosted or public LibreTranslate instance. +/// +public class LibreTranslateTranslator : ITranslator +{ + private const string DefaultEndpoint = "https://libretranslate.com/translate"; + private readonly IHttpClientFactory _httpClientFactory; + private readonly AutoTranslationOptions _options; + private readonly ILogger _logger; + + public LibreTranslateTranslator( + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _options = options.Value; + _logger = logger; + } + + public async Task TranslateAsync(string text, string sourceCulture, string targetCulture, bool isHtml, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text)) + { + return text; + } + + var sourceLang = ToLanguageCode(sourceCulture); + var targetLang = ToLanguageCode(targetCulture); + + if (string.Equals(sourceLang, targetLang, StringComparison.OrdinalIgnoreCase)) + { + return text; + } + + var endpoint = string.IsNullOrWhiteSpace(_options.Endpoint) ? DefaultEndpoint : _options.Endpoint; + + try + { + var client = _httpClientFactory.CreateClient(nameof(LibreTranslateTranslator)); + var payload = new + { + q = text, + source = sourceLang, + target = targetLang, + format = isHtml ? "html" : "text", + api_key = _options.ApiKey ?? string.Empty + }; + + using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + using var response = await client.PostAsync(endpoint, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("translatedText", out var translated)) + { + return translated.GetString() ?? text; + } + + return text; + } + catch (Exception ex) + { + _logger.LogError(ex, "LibreTranslate failed for {Source}->{Target}", sourceLang, targetLang); + return text; + } + } + + private static string ToLanguageCode(string culture) + { + if (string.IsNullOrWhiteSpace(culture)) + { + return "auto"; + } + var dash = culture.IndexOf('-'); + return (dash > 0 ? culture[..dash] : culture).ToLowerInvariant(); + } +} diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/TranslatorFactory.cs b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/TranslatorFactory.cs new file mode 100644 index 0000000..3dde1d6 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/Services/TranslatorFactory.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using uTPro.Feature.AutoTranslation.Configuration; + +namespace uTPro.Feature.AutoTranslation.Services; + +public interface ITranslatorFactory +{ + /// + /// Resolve the active based on . + /// + ITranslator Create(); +} + +public class TranslatorFactory : ITranslatorFactory +{ + private readonly IServiceProvider _serviceProvider; + private readonly AutoTranslationOptions _options; + + public TranslatorFactory(IServiceProvider serviceProvider, IOptions options) + { + _serviceProvider = serviceProvider; + _options = options.Value; + } + + public ITranslator Create() + { + var provider = (_options.Provider ?? "Google").Trim(); + return provider.ToLowerInvariant() switch + { + "deepl" => _serviceProvider.GetRequiredService(), + "libre" or "libretranslate" => _serviceProvider.GetRequiredService(), + _ => _serviceProvider.GetRequiredService(), + }; + } +} diff --git a/uTPro/Feature/uTPro.Feature.AutoTranslation/uTPro.Feature.AutoTranslation.csproj b/uTPro/Feature/uTPro.Feature.AutoTranslation/uTPro.Feature.AutoTranslation.csproj new file mode 100644 index 0000000..c291843 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.AutoTranslation/uTPro.Feature.AutoTranslation.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/uTPro/Feature/uTPro.Feature/uTPro.Feature.csproj b/uTPro/Feature/uTPro.Feature/uTPro.Feature.csproj index 125f4c9..f3c0908 100644 --- a/uTPro/Feature/uTPro.Feature/uTPro.Feature.csproj +++ b/uTPro/Feature/uTPro.Feature/uTPro.Feature.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/auto-translate-action.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/auto-translate-action.js new file mode 100644 index 0000000..168b788 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/auto-translate-action.js @@ -0,0 +1,208 @@ +/** + * uTPro Auto Translation - Workspace Action for Document & Media + * + * Umbraco 16 workspace action that translates text fields from the default + * language to the current variant language. + */ + +const API_BASE = '/umbraco/api/utpro/auto-translation'; + +export class AutoTranslateWorkspaceAction { + + host; + args; + + constructor(host, args) { + this.host = host; + this.args = args; + } + + /** + * Called by Umbraco when the workspace action button is clicked. + */ + async execute() { + console.log('[AutoTranslation] Execute called'); + + try { + // Get auth token + const token = await this._getToken(); + console.log('[AutoTranslation] Token obtained:', !!token); + + // Get current workspace info from URL + const workspaceInfo = this._getWorkspaceInfoFromUrl(); + console.log('[AutoTranslation] Workspace info:', workspaceInfo); + + if (!workspaceInfo.unique) { + alert('Auto Translation: Could not determine the current item. Please save the item first.'); + return; + } + + if (!workspaceInfo.culture) { + alert('Auto Translation: Please switch to a non-default language variant first (use the language selector at the top right).'); + return; + } + + // Call the API to translate the persisted content + const apiType = workspaceInfo.entityType === 'media' ? 'media' : 'content'; + const url = `${API_BASE}/${apiType}/${workspaceInfo.unique}?targetCulture=${encodeURIComponent(workspaceInfo.culture)}`; + + console.log('[AutoTranslation] Calling API:', url); + + const headers = { 'Content-Type': 'application/json' }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(url, { method: 'GET', headers, credentials: 'same-origin' }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[AutoTranslation] API error:', response.status, errorText); + alert(`Auto Translation failed: ${response.status} - ${errorText}`); + return; + } + + const result = await response.json(); + console.log('[AutoTranslation] API result:', result); + + if (result && result.values && Object.keys(result.values).length > 0) { + const count = Object.keys(result.values).length; + const valuesText = Object.entries(result.values) + .filter(([_, val]) => val) + .map(([key, val]) => `• ${key}: ${val?.substring(0, 80)}`) + .join('\n'); + + alert(`✅ Auto Translation completed!\n\nTranslated & saved ${count} field(s):\n${result.sourceCulture} → ${result.targetCulture}\n\nReloading page to show translated values...`); + + // Reload the page to show the saved translated values + window.location.reload(); + } else { + alert('Auto Translation: No translatable text fields found in the default language, or the item has no saved content yet.\n\nMake sure you have saved the item with content in the default language first.'); + } + + } catch (error) { + console.error('[AutoTranslation] Error:', error); + alert(`Auto Translation error: ${error.message}`); + } + } + + /** + * Get the auth token from Umbraco's auth system. + */ + async _getToken() { + try { + // Method 1: Try getContext (UmbControllerHostElement) + if (this.host && typeof this.host.getContext === 'function') { + try { + const authContext = await this.host.getContext('UMB_AUTH_CONTEXT'); + if (authContext && authContext.getOpenApiToken) { + const token = await authContext.getOpenApiToken(); + if (token) return token; + } + } catch (e) { + console.warn('[AutoTranslation] getContext failed:', e); + } + } + + // Method 2: Try consumeContext (callback-based) + if (this.host && typeof this.host.consumeContext === 'function') { + const token = await new Promise((resolve) => { + let resolved = false; + this.host.consumeContext('UMB_AUTH_CONTEXT', (authContext) => { + if (resolved) return; + resolved = true; + if (authContext && authContext.getOpenApiToken) { + authContext.getOpenApiToken().then(resolve).catch(() => resolve(null)); + } else { + resolve(null); + } + }); + setTimeout(() => { if (!resolved) { resolved = true; resolve(null); } }, 3000); + }); + if (token) return token; + } + + // Method 3: Try to find auth token from Umbraco's stored token + // Umbraco 16 stores the token in sessionStorage or via OIDC client + const storedKeys = Object.keys(sessionStorage); + for (const key of storedKeys) { + if (key.includes('oidc') || key.includes('token') || key.includes('auth')) { + try { + const data = JSON.parse(sessionStorage.getItem(key)); + if (data && data.access_token) { + return data.access_token; + } + } catch (e) { /* not JSON */ } + } + } + + // Method 4: Try localStorage + const localKeys = Object.keys(localStorage); + for (const key of localKeys) { + if (key.includes('oidc') || key.includes('token') || key.includes('auth')) { + try { + const data = JSON.parse(localStorage.getItem(key)); + if (data && data.access_token) { + return data.access_token; + } + } catch (e) { /* not JSON */ } + } + } + + } catch (e) { + console.warn('[AutoTranslation] Could not get auth token:', e); + } + return null; + } + + /** + * Extract workspace info from the current URL hash/path. + */ + _getWorkspaceInfoFromUrl() { + // Full URL path (Umbraco 16 uses path-based routing, not hash) + const fullPath = window.location.pathname + window.location.hash; + const info = { unique: null, entityType: 'document', culture: null }; + + console.log('[AutoTranslation] Full path:', fullPath); + + // Pattern: /workspace/document/edit/{guid}/{culture}/... + // Pattern: /workspace/media/edit/{guid}/{culture}/... + // GUID can be 32-36 chars with dashes + const editMatch = fullPath.match(/\/workspace\/(\w+)\/edit\/([a-f0-9-]+?)\/([a-z]{2}-[A-Z]{2})/i); + if (editMatch) { + info.entityType = editMatch[1]; // 'document' or 'media' + info.unique = editMatch[2]; + info.culture = editMatch[3]; + console.log('[AutoTranslation] Parsed from path:', info); + return info; + } + + // Fallback: try to get GUID without culture + const guidMatch = fullPath.match(/\/edit\/([a-f0-9-]+)/i); + if (guidMatch) { + info.unique = guidMatch[1]; + } + + if (fullPath.includes('/media/')) { + info.entityType = 'media'; + } + + // Try to get culture from URL segments + const cultureMatch = fullPath.match(/\/([a-z]{2}-[A-Z]{2})\//); + if (cultureMatch) { + info.culture = cultureMatch[1]; + } + + // Fallback: query params + if (!info.culture) { + const urlParams = new URLSearchParams(window.location.search); + info.culture = urlParams.get('culture') || urlParams.get('variantId'); + } + + console.log('[AutoTranslation] Parsed info:', info); + return info; + } +} + +export { AutoTranslateWorkspaceAction as api }; +export default AutoTranslateWorkspaceAction; diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/auto-translate-dictionary-action.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/auto-translate-dictionary-action.js new file mode 100644 index 0000000..eec9f57 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/auto-translate-dictionary-action.js @@ -0,0 +1,275 @@ +/** + * uTPro Auto Translation - Workspace Action for Dictionary (Translation section) + * + * When editing a dictionary item, this action translates the default language value + * into all other language fields automatically using the API. + */ + +const API_BASE = '/umbraco/api/utpro/auto-translation'; + +export class AutoTranslateDictionaryAction { + + host; + args; + + constructor(host, args) { + this.host = host; + this.args = args; + } + + /** + * Called by Umbraco when the workspace action button is clicked. + */ + async execute() { + console.log('[AutoTranslation Dictionary] Execute called'); + + try { + // Find all text input fields (textarea, input, umb-input-textarea, etc.) + const fields = this._findDictionaryFields(); + console.log('[AutoTranslation Dictionary] Found fields:', fields.length, fields); + + if (fields.length < 2) { + alert('Auto Translation: Need at least 2 language fields to translate.'); + return; + } + + // Find the source field (first non-empty one - typically the default language) + let sourceField = null; + let sourceIndex = -1; + + for (let i = 0; i < fields.length; i++) { + const value = this._getFieldValue(fields[i]); + if (value && value.trim()) { + sourceField = fields[i]; + sourceIndex = i; + break; + } + } + + if (!sourceField) { + alert('Auto Translation: Please enter text in the default language field first.'); + return; + } + + const sourceText = this._getFieldValue(sourceField); + console.log('[AutoTranslation Dictionary] Source text:', sourceText); + + // Determine cultures from labels + const cultures = this._detectCultures(fields); + console.log('[AutoTranslation Dictionary] Detected cultures:', cultures); + + const sourceCulture = cultures[sourceIndex] || 'vi-VN'; + + let translatedCount = 0; + + for (let i = 0; i < fields.length; i++) { + if (i === sourceIndex) continue; + + const currentValue = this._getFieldValue(fields[i]); + if (currentValue && currentValue.trim()) continue; // Skip non-empty + + const targetCulture = cultures[i] || 'en-US'; + + console.log(`[AutoTranslation Dictionary] Translating "${sourceText}" to ${targetCulture}...`); + + try { + const response = await fetch(`${API_BASE}/text`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + text: sourceText, + sourceCulture: sourceCulture, + targetCulture: targetCulture, + isHtml: false + }) + }); + + if (response.ok) { + const result = await response.json(); + console.log(`[AutoTranslation Dictionary] Result:`, result); + + if (result && result.text) { + this._setFieldValue(fields[i], result.text); + translatedCount++; + } + } else { + console.error('[AutoTranslation Dictionary] API error:', response.status); + } + } catch (e) { + console.error(`[AutoTranslation Dictionary] Failed:`, e); + } + } + + if (translatedCount > 0) { + alert(`✅ Auto Translation: Successfully translated ${translatedCount} language field(s)!\n\nClick Save to persist the changes.`); + } else { + alert('Auto Translation: No empty fields to translate into. Clear the target fields first.'); + } + + } catch (error) { + console.error('[AutoTranslation Dictionary] Error:', error); + alert(`Auto Translation error: ${error.message}`); + } + } + + /** + * Find all dictionary translation input fields in the page. + * Searches through Shadow DOM as Umbraco 16 uses web components. + */ + _findDictionaryFields() { + const fields = []; + + // Strategy 1: Find textareas directly + const textareas = document.querySelectorAll('textarea'); + if (textareas.length >= 2) { + return Array.from(textareas); + } + + // Strategy 2: Search in shadow DOMs + const allTextareas = this._querySelectorAllDeep('textarea'); + if (allTextareas.length >= 2) { + return allTextareas; + } + + // Strategy 3: Find umb-input-textarea or similar custom elements + const umbInputs = this._querySelectorAllDeep('umb-input-textarea, umb-textarea, [type="textarea"]'); + if (umbInputs.length >= 2) { + return umbInputs; + } + + // Strategy 4: Find any input/textarea inside property editors + const inputs = this._querySelectorAllDeep('input[type="text"], textarea, [contenteditable="true"]'); + if (inputs.length >= 2) { + return inputs; + } + + // Strategy 5: Fallback - find all text inputs + return Array.from(document.querySelectorAll('input[type="text"], textarea')); + } + + /** + * Deep querySelector that traverses shadow DOMs. + */ + _querySelectorAllDeep(selector) { + const results = []; + const traverse = (root) => { + const found = root.querySelectorAll(selector); + results.push(...found); + + // Traverse shadow roots + const allElements = root.querySelectorAll('*'); + for (const el of allElements) { + if (el.shadowRoot) { + traverse(el.shadowRoot); + } + } + }; + traverse(document); + return results; + } + + /** + * Get the value from a field element. + */ + _getFieldValue(field) { + if (field.value !== undefined) { + return field.value; + } + if (field.textContent) { + return field.textContent; + } + return ''; + } + + /** + * Set value on a field element, triggering framework reactivity. + */ + _setFieldValue(field, value) { + // Try native value setter for textarea/input + const proto = field instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + + const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; + + if (nativeSetter) { + nativeSetter.call(field, value); + } else { + field.value = value; + } + + // Dispatch events to notify the framework + field.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + field.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + field.dispatchEvent(new Event('blur', { bubbles: true, composed: true })); + } + + /** + * Try to detect culture codes from field labels. + */ + _detectCultures(fields) { + const cultures = []; + const cultureMap = { + 'english': 'en-US', + 'united states': 'en-US', + 'vietnamese': 'vi-VN', + 'vietnam': 'vi-VN', + 'french': 'fr-FR', + 'german': 'de-DE', + 'spanish': 'es-ES', + 'chinese': 'zh-CN', + 'japanese': 'ja-JP', + 'korean': 'ko-KR' + }; + + for (const field of fields) { + let culture = null; + + // Try to find a label near the field + const parent = field.closest('[class*="property"], [class*="field"], tr, .umb-property, div'); + if (parent) { + const label = parent.querySelector('label, .label, [class*="label"]'); + if (label) { + const labelText = label.textContent.toLowerCase(); + for (const [key, code] of Object.entries(cultureMap)) { + if (labelText.includes(key)) { + culture = code; + break; + } + } + } + } + + // Fallback: check preceding sibling or parent text + if (!culture) { + const prevEl = field.previousElementSibling; + if (prevEl) { + const text = prevEl.textContent?.toLowerCase() || ''; + for (const [key, code] of Object.entries(cultureMap)) { + if (text.includes(key)) { + culture = code; + break; + } + } + } + } + + cultures.push(culture); + } + + // If we couldn't detect, use defaults based on your setup + if (cultures.every(c => c === null)) { + // Based on your Umbraco setup: first field = English, second = Vietnamese + if (cultures.length >= 2) { + cultures[0] = 'en-US'; + cultures[1] = 'vi-VN'; + } + } + + return cultures; + } +} + +export { AutoTranslateDictionaryAction as api }; +export default AutoTranslateDictionaryAction; diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/lang/en-us.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/lang/en-us.js new file mode 100644 index 0000000..8abe928 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/lang/en-us.js @@ -0,0 +1,11 @@ +export default { + autoTranslation: { + buttonLabel: "Auto Translate", + translating: "Translating...", + success: "Translation completed successfully!", + error: "Translation failed. Please try again.", + noFields: "No translatable text fields found.", + sameLanguage: "Source and target languages are the same.", + selectLanguage: "Please switch to a non-default language variant first." + } +}; diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/lang/vi-vn.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/lang/vi-vn.js new file mode 100644 index 0000000..8c434f6 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/lang/vi-vn.js @@ -0,0 +1,11 @@ +export default { + autoTranslation: { + buttonLabel: "Tự động dịch", + translating: "Đang dịch...", + success: "Dịch thành công!", + error: "Dịch thất bại. Vui lòng thử lại.", + noFields: "Không tìm thấy trường văn bản nào để dịch.", + sameLanguage: "Ngôn ngữ nguồn và đích giống nhau.", + selectLanguage: "Vui lòng chuyển sang ngôn ngữ khác (không phải ngôn ngữ mặc định) trước." + } +}; diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/umbraco-package.json b/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/umbraco-package.json new file mode 100644 index 0000000..f1979cc --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/auto-translation/umbraco-package.json @@ -0,0 +1,82 @@ +{ + "alias": "uTPro.AutoTranslation", + "name": "uTPro Auto Translation", + "version": "1.0.0", + "extensions": [ + { + "type": "workspaceAction", + "kind": "default", + "alias": "uTPro.AutoTranslation.Action.Document", + "name": "Auto Translate Document", + "api": "/App_Plugins/auto-translation/auto-translate-action.js", + "weight": 900, + "meta": { + "label": "Auto Translate", + "look": "secondary", + "color": "default" + }, + "conditions": [ + { + "alias": "Umb.Condition.WorkspaceAlias", + "match": "Umb.Workspace.Document" + } + ] + }, + { + "type": "workspaceAction", + "kind": "default", + "alias": "uTPro.AutoTranslation.Action.Media", + "name": "Auto Translate Media", + "api": "/App_Plugins/auto-translation/auto-translate-action.js", + "weight": 900, + "meta": { + "label": "Auto Translate", + "look": "secondary", + "color": "default" + }, + "conditions": [ + { + "alias": "Umb.Condition.WorkspaceAlias", + "match": "Umb.Workspace.Media" + } + ] + }, + { + "type": "workspaceAction", + "kind": "default", + "alias": "uTPro.AutoTranslation.Action.Dictionary", + "name": "Auto Translate Dictionary", + "api": "/App_Plugins/auto-translation/auto-translate-dictionary-action.js", + "weight": 900, + "meta": { + "label": "Auto Translate", + "look": "secondary", + "color": "default" + }, + "conditions": [ + { + "alias": "Umb.Condition.WorkspaceAlias", + "match": "Umb.Workspace.Dictionary" + } + ] + }, + { + "type": "localization", + "alias": "uTPro.AutoTranslation.Localize.EnUS", + "name": "Auto Translation English", + "js": "/App_Plugins/auto-translation/lang/en-us.js", + "meta": { + "culture": "en-US" + } + }, + { + "type": "localization", + "alias": "uTPro.AutoTranslation.Localize.ViVN", + "name": "Auto Translation Vietnamese", + "js": "/App_Plugins/auto-translation/lang/vi-vn.js", + "meta": { + "culture": "vi-VN" + } + } + ] +} diff --git a/uTPro/Project/uTPro.Project.Web/appsettings.json b/uTPro/Project/uTPro.Project.Web/appsettings.json index 9840714..016509f 100644 --- a/uTPro/Project/uTPro.Project.Web/appsettings.json +++ b/uTPro/Project/uTPro.Project.Web/appsettings.json @@ -28,6 +28,23 @@ ] } }, + "AutoTranslation": { + "Enabled": true, + "Provider": "Google", + "ApiKey": "", + "Endpoint": "", + "AllowedEditors": [ + "Umbraco.TextBox", + "Umbraco.TextArea", + "Umbraco.TinyMCE", + "Umbraco.RichText", + "Umbraco.Plain.String", + "Umbraco.Plain.Text", + "Umbraco.MultipleTextstring", + "Umbraco.Markdown.Editor", + "Umbraco.MarkdownEditor" + ] + }, "Hosting": { "TempPath": "" } diff --git a/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj b/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj index c7aaf16..d034679 100644 --- a/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj +++ b/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj @@ -15,6 +15,10 @@ + + + + @@ -28,6 +32,7 @@ + diff --git a/uTPro/uTPro.sln b/uTPro/uTPro.sln index 2a5da96..f739b8f 100644 --- a/uTPro/uTPro.sln +++ b/uTPro/uTPro.sln @@ -35,56 +35,162 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "uTPro.Feature", "Feature\uT EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Project", "Project", "{9C8C527F-5B16-429C-8B61-D129E2D5A503}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "uTPro.Feature.AutoTranslation", "Feature\uTPro.Feature.AutoTranslation\uTPro.Feature.AutoTranslation.csproj", "{CCB5A103-130A-47C3-B942-D95056C946C9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Debug|x64.ActiveCfg = Debug|Any CPU + {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Debug|x64.Build.0 = Debug|Any CPU + {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Debug|x86.ActiveCfg = Debug|Any CPU + {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Debug|x86.Build.0 = Debug|Any CPU {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Release|Any CPU.ActiveCfg = Release|Any CPU {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Release|Any CPU.Build.0 = Release|Any CPU + {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Release|x64.ActiveCfg = Release|Any CPU + {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Release|x64.Build.0 = Release|Any CPU + {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Release|x86.ActiveCfg = Release|Any CPU + {879135C7-F935-4DD9-B0D9-0DA4473434FD}.Release|x86.Build.0 = Release|Any CPU {C21BC922-C155-443B-A6C9-773154D68655}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C21BC922-C155-443B-A6C9-773154D68655}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C21BC922-C155-443B-A6C9-773154D68655}.Debug|x64.ActiveCfg = Debug|Any CPU + {C21BC922-C155-443B-A6C9-773154D68655}.Debug|x64.Build.0 = Debug|Any CPU + {C21BC922-C155-443B-A6C9-773154D68655}.Debug|x86.ActiveCfg = Debug|Any CPU + {C21BC922-C155-443B-A6C9-773154D68655}.Debug|x86.Build.0 = Debug|Any CPU {C21BC922-C155-443B-A6C9-773154D68655}.Release|Any CPU.ActiveCfg = Release|Any CPU {C21BC922-C155-443B-A6C9-773154D68655}.Release|Any CPU.Build.0 = Release|Any CPU + {C21BC922-C155-443B-A6C9-773154D68655}.Release|x64.ActiveCfg = Release|Any CPU + {C21BC922-C155-443B-A6C9-773154D68655}.Release|x64.Build.0 = Release|Any CPU + {C21BC922-C155-443B-A6C9-773154D68655}.Release|x86.ActiveCfg = Release|Any CPU + {C21BC922-C155-443B-A6C9-773154D68655}.Release|x86.Build.0 = Release|Any CPU {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Debug|x64.ActiveCfg = Debug|Any CPU + {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Debug|x64.Build.0 = Debug|Any CPU + {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Debug|x86.Build.0 = Debug|Any CPU {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Release|Any CPU.ActiveCfg = Release|Any CPU {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Release|Any CPU.Build.0 = Release|Any CPU + {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Release|x64.ActiveCfg = Release|Any CPU + {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Release|x64.Build.0 = Release|Any CPU + {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Release|x86.ActiveCfg = Release|Any CPU + {C733CF85-1EF8-4E9B-938C-C289C6D457FE}.Release|x86.Build.0 = Release|Any CPU {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Debug|x64.ActiveCfg = Debug|Any CPU + {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Debug|x64.Build.0 = Debug|Any CPU + {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Debug|x86.ActiveCfg = Debug|Any CPU + {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Debug|x86.Build.0 = Debug|Any CPU {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Release|Any CPU.Build.0 = Release|Any CPU + {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Release|x64.ActiveCfg = Release|Any CPU + {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Release|x64.Build.0 = Release|Any CPU + {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Release|x86.ActiveCfg = Release|Any CPU + {D6B1FBC3-AA27-408C-9EAB-DA4342243B1F}.Release|x86.Build.0 = Release|Any CPU {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Debug|x64.ActiveCfg = Debug|Any CPU + {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Debug|x64.Build.0 = Debug|Any CPU + {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Debug|x86.ActiveCfg = Debug|Any CPU + {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Debug|x86.Build.0 = Debug|Any CPU {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Release|Any CPU.ActiveCfg = Release|Any CPU {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Release|Any CPU.Build.0 = Release|Any CPU + {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Release|x64.ActiveCfg = Release|Any CPU + {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Release|x64.Build.0 = Release|Any CPU + {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Release|x86.ActiveCfg = Release|Any CPU + {76AB44FC-2A28-48D4-BBDF-7D8E0CD1034A}.Release|x86.Build.0 = Release|Any CPU {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Debug|x64.ActiveCfg = Debug|Any CPU + {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Debug|x64.Build.0 = Debug|Any CPU + {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Debug|x86.ActiveCfg = Debug|Any CPU + {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Debug|x86.Build.0 = Debug|Any CPU {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Release|Any CPU.ActiveCfg = Release|Any CPU {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Release|Any CPU.Build.0 = Release|Any CPU + {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Release|x64.ActiveCfg = Release|Any CPU + {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Release|x64.Build.0 = Release|Any CPU + {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Release|x86.ActiveCfg = Release|Any CPU + {EF2F6E2B-BBDC-4805-B804-C816E90BC09C}.Release|x86.Build.0 = Release|Any CPU {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Debug|x64.ActiveCfg = Debug|Any CPU + {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Debug|x64.Build.0 = Debug|Any CPU + {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Debug|x86.ActiveCfg = Debug|Any CPU + {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Debug|x86.Build.0 = Debug|Any CPU {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Release|Any CPU.ActiveCfg = Release|Any CPU {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Release|Any CPU.Build.0 = Release|Any CPU + {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Release|x64.ActiveCfg = Release|Any CPU + {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Release|x64.Build.0 = Release|Any CPU + {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Release|x86.ActiveCfg = Release|Any CPU + {0AC65E28-B693-4D06-A00C-7E9B716C9804}.Release|x86.Build.0 = Release|Any CPU {6D0EB8DD-D697-4381-A4DD-B29468734209}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6D0EB8DD-D697-4381-A4DD-B29468734209}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D0EB8DD-D697-4381-A4DD-B29468734209}.Debug|x64.ActiveCfg = Debug|Any CPU + {6D0EB8DD-D697-4381-A4DD-B29468734209}.Debug|x64.Build.0 = Debug|Any CPU + {6D0EB8DD-D697-4381-A4DD-B29468734209}.Debug|x86.ActiveCfg = Debug|Any CPU + {6D0EB8DD-D697-4381-A4DD-B29468734209}.Debug|x86.Build.0 = Debug|Any CPU {6D0EB8DD-D697-4381-A4DD-B29468734209}.Release|Any CPU.ActiveCfg = Release|Any CPU {6D0EB8DD-D697-4381-A4DD-B29468734209}.Release|Any CPU.Build.0 = Release|Any CPU + {6D0EB8DD-D697-4381-A4DD-B29468734209}.Release|x64.ActiveCfg = Release|Any CPU + {6D0EB8DD-D697-4381-A4DD-B29468734209}.Release|x64.Build.0 = Release|Any CPU + {6D0EB8DD-D697-4381-A4DD-B29468734209}.Release|x86.ActiveCfg = Release|Any CPU + {6D0EB8DD-D697-4381-A4DD-B29468734209}.Release|x86.Build.0 = Release|Any CPU {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Debug|x64.ActiveCfg = Debug|Any CPU + {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Debug|x64.Build.0 = Debug|Any CPU + {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Debug|x86.ActiveCfg = Debug|Any CPU + {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Debug|x86.Build.0 = Debug|Any CPU {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Release|Any CPU.ActiveCfg = Release|Any CPU {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Release|Any CPU.Build.0 = Release|Any CPU + {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Release|x64.ActiveCfg = Release|Any CPU + {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Release|x64.Build.0 = Release|Any CPU + {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Release|x86.ActiveCfg = Release|Any CPU + {2017A7B3-BBB0-4F75-86CA-1280432764A2}.Release|x86.Build.0 = Release|Any CPU {B4167671-FAAC-45F5-AF04-04FF196046FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B4167671-FAAC-45F5-AF04-04FF196046FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4167671-FAAC-45F5-AF04-04FF196046FB}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4167671-FAAC-45F5-AF04-04FF196046FB}.Debug|x64.Build.0 = Debug|Any CPU + {B4167671-FAAC-45F5-AF04-04FF196046FB}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4167671-FAAC-45F5-AF04-04FF196046FB}.Debug|x86.Build.0 = Debug|Any CPU {B4167671-FAAC-45F5-AF04-04FF196046FB}.Release|Any CPU.ActiveCfg = Release|Any CPU {B4167671-FAAC-45F5-AF04-04FF196046FB}.Release|Any CPU.Build.0 = Release|Any CPU + {B4167671-FAAC-45F5-AF04-04FF196046FB}.Release|x64.ActiveCfg = Release|Any CPU + {B4167671-FAAC-45F5-AF04-04FF196046FB}.Release|x64.Build.0 = Release|Any CPU + {B4167671-FAAC-45F5-AF04-04FF196046FB}.Release|x86.ActiveCfg = Release|Any CPU + {B4167671-FAAC-45F5-AF04-04FF196046FB}.Release|x86.Build.0 = Release|Any CPU {E606C629-3B37-458C-9C80-D555A762E28E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E606C629-3B37-458C-9C80-D555A762E28E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E606C629-3B37-458C-9C80-D555A762E28E}.Debug|x64.ActiveCfg = Debug|Any CPU + {E606C629-3B37-458C-9C80-D555A762E28E}.Debug|x64.Build.0 = Debug|Any CPU + {E606C629-3B37-458C-9C80-D555A762E28E}.Debug|x86.ActiveCfg = Debug|Any CPU + {E606C629-3B37-458C-9C80-D555A762E28E}.Debug|x86.Build.0 = Debug|Any CPU {E606C629-3B37-458C-9C80-D555A762E28E}.Release|Any CPU.ActiveCfg = Release|Any CPU {E606C629-3B37-458C-9C80-D555A762E28E}.Release|Any CPU.Build.0 = Release|Any CPU + {E606C629-3B37-458C-9C80-D555A762E28E}.Release|x64.ActiveCfg = Release|Any CPU + {E606C629-3B37-458C-9C80-D555A762E28E}.Release|x64.Build.0 = Release|Any CPU + {E606C629-3B37-458C-9C80-D555A762E28E}.Release|x86.ActiveCfg = Release|Any CPU + {E606C629-3B37-458C-9C80-D555A762E28E}.Release|x86.Build.0 = Release|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Debug|x64.ActiveCfg = Debug|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Debug|x64.Build.0 = Debug|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Debug|x86.ActiveCfg = Debug|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Debug|x86.Build.0 = Debug|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Release|Any CPU.Build.0 = Release|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Release|x64.ActiveCfg = Release|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Release|x64.Build.0 = Release|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Release|x86.ActiveCfg = Release|Any CPU + {CCB5A103-130A-47C3-B942-D95056C946C9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -101,6 +207,7 @@ Global {2017A7B3-BBB0-4F75-86CA-1280432764A2} = {C47DDDB1-58C4-4FEC-BB0C-1E452FB526E1} {B4167671-FAAC-45F5-AF04-04FF196046FB} = {C47DDDB1-58C4-4FEC-BB0C-1E452FB526E1} {E606C629-3B37-458C-9C80-D555A762E28E} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {CCB5A103-130A-47C3-B942-D95056C946C9} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EEF559D3-C416-4C76-A0BD-CD923A2994F8}