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}