From b328e820e955be6c80e1a1b015a2bfca6a106d4f Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 25 Apr 2024 10:39:43 -0600 Subject: [PATCH 01/10] Experiment with DeepL Translation for source bubbles See https://www.notion.so/hattonjohn/AI-Translation-df49684234844944988669d23629c508?pvs=4 --- .../sourceBubbles/BloomSourceBubbles.tsx | 35 +++++++++++++++++++ src/BloomExe/WebView2Browser.cs | 2 ++ 2 files changed, 37 insertions(+) diff --git a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx index 518bb5056c11..676c67de1d9a 100644 --- a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx +++ b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx @@ -17,6 +17,7 @@ import "../../lib/jquery.easytabs.js"; //load into global space import BloomHintBubbles from "../js/BloomHintBubbles"; import { postJson, postString } from "../../utils/bloomApi"; import CopyContentButton from "../../react_components/CopyContentButton"; +import axios from "axios"; declare function GetSettings(): any; // C# (or test code) injects this @@ -105,6 +106,40 @@ export default class BloomSourceBubbles { $(element).remove(); }); + // get the first div that has a lang attribute of either "en" or "es" + const sourceDiv = divForBubble.find("div[lang='en']").first(); + if (sourceDiv.length > 0 && sourceDiv.text().length > 0) { + const targetDiv = document.createElement("div"); + targetDiv.setAttribute("lang", "fr-x-ai"); + targetDiv.className = "bloom-editable source-text"; + targetDiv.innerText = "waiting"; + divForBubble.append(targetDiv); + // Now make a call to deepl api to translate that into French. When the call returns, set the text of that div to the French translation. + axios + .post( + "https://api-free.deepl.com/v2/translate", + { + text: [sourceDiv.text()], + source_lang: sourceDiv.attr("lang"), + target_lang: "FR" + }, + { + headers: { + Authorization: + "DeepL-Auth-Key ", + "Content-Type": "application/json" + } + } + ) + .then(response => { + targetDiv.innerText = response.data.translations[0].text; + }) + .catch(error => { + targetDiv.innerText = "error " + error; + }); + } + + // make a call to deepl api to translate "bonjour le monde" into English. Then //make the source texts in the bubble read-only and remove any user font size adjustments divForBubble.find("textarea, div").each(function (): boolean { //don't want empty items in the bubble diff --git a/src/BloomExe/WebView2Browser.cs b/src/BloomExe/WebView2Browser.cs index b66b1f43f53b..5bfe73b93c71 100644 --- a/src/BloomExe/WebView2Browser.cs +++ b/src/BloomExe/WebView2Browser.cs @@ -395,6 +395,8 @@ private async Task InitWebView() if (!string.IsNullOrEmpty(_uiLanguageOfThisRun)) { additionalBrowserArgs += " --accept-lang=" + _uiLanguageOfThisRun; + // turn off CORS for experiment with DeepL translation + additionalBrowserArgs += " --disable-web-security"; } if (RemoteDebuggingPort.HasValue) { From c6555c78eb1e90374009bab22e478f9e75305932 Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 25 Apr 2024 10:40:23 -0600 Subject: [PATCH 02/10] Another ai translation experiment, unfinished, doing from c# --- src/BloomExe/Book/Book.cs | 72 +++++++++++++++++++++++++++++++ src/BloomExe/Edit/EditingModel.cs | 4 ++ 2 files changed, 76 insertions(+) diff --git a/src/BloomExe/Book/Book.cs b/src/BloomExe/Book/Book.cs index 621e2d7bf032..045414fa8646 100644 --- a/src/BloomExe/Book/Book.cs +++ b/src/BloomExe/Book/Book.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; @@ -6140,5 +6141,76 @@ public bool IsPlayground ?? false; } } + + public void AITranslate() + { + var groups = this.Storage.Dom.RawDom.SelectNodes( + "//div[contains(@class, 'bloom-translationGroup')]" + ); + var sourceLang = "en"; + var targetLang = "es"; + foreach (XmlNode group in groups) + { + var node = group.SelectSingleNode($"div[@lang = '{targetLang}-x-ai']"); + if (node != null) + { + // for now, delete it + group.RemoveChild(node); + } + var sourceEditable = group.SelectSingleNode($"div[@lang = '{sourceLang}']"); + if (sourceEditable != null) + { + var sourceText = sourceEditable.InnerText; + var editable = this.Storage.Dom.RawDom.CreateElement("div"); + editable.SetAttribute("class", "bloom-editable"); + editable.SetAttribute("lang", $"{targetLang}-x-ai"); + group.AppendChild(editable); + Translate( + sourceText, + sourceLang, + targetLang, + result => editable.InnerText = result + ); + } + } + } + + private void Translate( + string sourceText, + string sourceLang, + string targetLang, + Action setValue + ) + { + var client = new HttpClient(); + + //try + //{ + // var airequest = new + // { + // text = new[] { sourceText }, + // source_lang = sourceLang, + // target_lang = targetLang + // }; + // // make a request to the DeepL API using post + // var request = new HttpRequestMessage(HttpMethod.Post, "https://api-free.deepl.com/v2/translate"); + // // set content type to json + // request.Content = new StringContent(JsonConvert.SerializeObject(airequest), Encoding.UTF8, "application/json"); + // request.Headers.Add("Authorization", "DeepL-Auth-Key 8e7a9639-542f-ee8a-d204-a99415a4755c:fx"); + + // var response = client.SendAsync(request).Result; + // // wait for the response + + + // response.EnsureSuccessStatusCode(); + + // var responseContent = await airequest.Content.ReadAsStringAsync(); + // dynamic responseData = Newtonsoft.Json.JsonConvert.DeserializeObject(responseContent); + // string translatedText = responseData.translations[0].text; + + // return translatedText; + //} + // } + } } } diff --git a/src/BloomExe/Edit/EditingModel.cs b/src/BloomExe/Edit/EditingModel.cs index 1c70e4d99c69..d2f6380aaad4 100644 --- a/src/BloomExe/Edit/EditingModel.cs +++ b/src/BloomExe/Edit/EditingModel.cs @@ -949,6 +949,10 @@ public void OnBecomeVisible() var contentLanguages = GetMultilingualContentLanguages(); CurrentBook.SetMultilingualContentLanguages(contentLanguages); CurrentBook.PrepareForEditing(); + + // kick off a task to call Translate() on the book + //new Task(() => CurrentBook.AITranslate()).Start(); + //CurrentBook.AITranslate(); } _currentlyDisplayedBook = CurrentBook; From 8fc9e70f60bca0d2728319ba9d86fd21c7d795f5 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 27 Apr 2026 18:08:51 -0600 Subject: [PATCH 03/10] Full initial AI Source bubbles --- DistFiles/localization/en/Bloom.xlf | 36 ++ .../bookEdit/js/collectionSettings.d.ts | 4 + .../sourceBubbles/BloomSourceBubbles.tsx | 564 ++++++++++++++++-- .../sourceBubbles/SourceBubblesSpec.ts | 309 +++++++++- .../collection/AdvancedSettingsPanel.tsx | 298 ++++++++- .../collection/LanguageChooserDialog.tsx | 47 +- .../AiSourceBubbles/AiSourceBubblesService.cs | 473 +++++++++++++++ src/BloomExe/Book/Book.cs | 11 +- .../Book/RuntimeInformationInjector.cs | 28 +- src/BloomExe/Collection/CollectionSettings.cs | 37 ++ .../Collection/CollectionSettingsDialog.cs | 39 ++ src/BloomExe/ExperimentalFeatures.cs | 1 + src/BloomExe/ProjectContext.cs | 4 + .../FeatureRegistry.cs | 7 + .../web/controllers/AiSourceBubblesApi.cs | 63 ++ .../web/controllers/CollectionSettingsApi.cs | 106 ++++ .../AiSourceBubblesServiceTests.cs | 193 ++++++ src/BloomTests/ExperimentalFeaturesTests.cs | 26 +- .../Subscription/FeatureStatusTests.cs | 46 ++ 19 files changed, 2212 insertions(+), 80 deletions(-) create mode 100644 src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs create mode 100644 src/BloomExe/web/controllers/AiSourceBubblesApi.cs create mode 100644 src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs diff --git a/DistFiles/localization/en/Bloom.xlf b/DistFiles/localization/en/Bloom.xlf index dd64c53c3e5c..230b713e225e 100644 --- a/DistFiles/localization/en/Bloom.xlf +++ b/DistFiles/localization/en/Bloom.xlf @@ -379,6 +379,42 @@ ID: CollectionSettingsDialog.AdvancedTab.Experimental.SpreadsheetImportExport This is the label of a checkbox which, if enabled, allows a menu command to be used that can import Bloom books from an Excel spreadsheet or to an Excel spreadsheet. + + AI Source Bubbles + ID: CollectionSettingsDialog.AdvancedTab.Experimental.AiSourceBubbles + + + AI Source Bubbles + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.SectionLabel + + + Provider + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.ProviderLabel + + + Target Language + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TargetLanguageLabel + + + Choose one of this collection's languages, or Other... to select another language. + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TargetLanguageDescription + + + Other... + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.OtherLanguageLabel + + + DeepL API key + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLApiKeyLabel + + + Google service account email + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GoogleServiceAccountEmailLabel + + + Google private key + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GooglePrivateKeyLabel + Bloom is switching the default font for "{0}" to the new "Andika New Basic". ID: CollectionSettingsDialog.AndikaNewBasicUpdate1 diff --git a/src/BloomBrowserUI/bookEdit/js/collectionSettings.d.ts b/src/BloomBrowserUI/bookEdit/js/collectionSettings.d.ts index b67b525e3b79..fd4cea87932f 100644 --- a/src/BloomBrowserUI/bookEdit/js/collectionSettings.d.ts +++ b/src/BloomBrowserUI/bookEdit/js/collectionSettings.d.ts @@ -9,6 +9,10 @@ interface ICollectionSettings { defaultSourceLanguage2: string; currentCollectionLanguage2: string; currentCollectionLanguage3: string; + allowAiSourceBubbles: boolean; + aiSourceBubblesProvider: string; + aiSourceBubblesTargetLanguageTag: string; + aiSourceBubblesLanguageTag: string; browserRoot: string; topics: string[]; } diff --git a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx index 676c67de1d9a..de66ae396e0a 100644 --- a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx +++ b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx @@ -9,19 +9,32 @@ // The actual function is injected by C#. /// import { renderRoot } from "../../utils/reactRender"; +import * as ReactDOM from "react-dom"; +import * as React from "react"; import $ from "jquery"; +import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome"; import theOneLocalizationManager from "../../lib/localizationManager/localizationManager"; import StyleEditor from "../StyleEditor/StyleEditor"; import bloomQtipUtils from "../js/bloomQtipUtils"; import "../../lib/jquery.easytabs.js"; //load into global space import BloomHintBubbles from "../js/BloomHintBubbles"; -import { postJson, postString } from "../../utils/bloomApi"; +import { getEditablePageBundleExports } from "../js/workspaceFrames"; +import { postJson, postJsonAsync, postString } from "../../utils/bloomApi"; import CopyContentButton from "../../react_components/CopyContentButton"; -import axios from "axios"; -declare function GetSettings(): any; // C# (or test code) injects this +declare function GetSettings(): ICollectionSettings; export default class BloomSourceBubbles { + private static readonly kAiSourceBubbleFingerprintAttr = + "data-ai-source-bubble-fingerprint"; + private static readonly kAiSourceBubblePendingFingerprintAttr = + "data-ai-source-bubble-pending-fingerprint"; + private static readonly kAiSourceBubbleRequestTokenAttr = + "data-ai-source-bubble-request-token"; + private static readonly kAiSourceBubbleClass = + "bloom-ai-source-bubble-translation"; + private static nextAiSourceBubbleRequestToken = 0; + //:empty is not quite enough... we don't want to show bubbles if all there is is an empty paragraph private static hasNoText(obj: HTMLElement): boolean { //if(typeof (obj) == 'HTMLTextAreaElement') { @@ -30,6 +43,457 @@ export default class BloomSourceBubbles { return $.trim($(obj).text()).length === 0; } + private static isAiLanguageTag(languageTag: string | undefined): boolean { + return !!languageTag && languageTag.includes("-x-ai"); + } + + private static getPreferredSourceDiv(divForBubble: JQuery): JQuery { + const settings = GetSettings(); + const preferredLanguages = [ + settings.defaultSourceLanguage, + settings.defaultSourceLanguage2, + settings.currentCollectionLanguage2, + settings.currentCollectionLanguage3, + "en", + ].filter( + (languageTag, index, tags) => + !!languageTag && + !BloomSourceBubbles.isAiLanguageTag(languageTag) && + tags.indexOf(languageTag) === index, + ); + + for (const languageTag of preferredLanguages) { + const matchingDiv = divForBubble + .find(`div[lang='${languageTag}']`) + .filter( + (index, element) => + !BloomSourceBubbles.hasNoText(element as HTMLElement), + ) + .first(); + if (matchingDiv.length > 0) { + return matchingDiv; + } + } + + return divForBubble + .find("div[lang]") + .filter((index, element) => { + const languageTag = element.getAttribute("lang") || ""; + return ( + !BloomSourceBubbles.isAiLanguageTag(languageTag) && + !BloomSourceBubbles.hasNoText(element as HTMLElement) + ); + }) + .first(); + } + + private static getAiSourceBubbleLangTag(): string | undefined { + const settings = GetSettings(); + if ( + !settings.allowAiSourceBubbles || + !settings.aiSourceBubblesLanguageTag + ) { + return undefined; + } + return settings.aiSourceBubblesLanguageTag; + } + + private static getAiSourceBubbleFingerprint( + sourceText: string, + sourceLanguageTag: string, + aiLanguageTag: string, + ): string { + return [ + sourceLanguageTag, + aiLanguageTag, + sourceText.length.toString(), + BloomSourceBubbles.hashText(sourceText), + ].join("|"); + } + + private static hashText(text: string): string { + let hash = 0; + for (let i = 0; i < text.length; i++) { + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + } + + return hash.toString(36); + } + + private static removeAiSourceBubbleDivs( + group: JQuery, + currentLangTag?: string, + ): void { + group.find("div[lang]").each((index, element) => { + const langTag = element.getAttribute("lang") || ""; + if (!langTag.includes("-x-ai")) { + return; + } + + if (currentLangTag && langTag === currentLangTag) { + return; + } + + element.remove(); + }); + } + + private static syncVisibleAiSourceBubble( + group: HTMLElement, + sourceAiDiv: HTMLDivElement, + ): void { + const qtipId = group.getAttribute("aria-describedby"); + if (!qtipId) { + return; + } + + const tooltip = group.ownerDocument.querySelector(`#${qtipId}`); + if (!tooltip) { + return; + } + + const tooltipAiDivs = tooltip.querySelectorAll( + `.${BloomSourceBubbles.kAiSourceBubbleClass}`, + ); + tooltipAiDivs.forEach((tooltipAiDiv) => { + tooltipAiDiv.setAttribute( + "lang", + sourceAiDiv.getAttribute("lang") || "", + ); + + const fingerprint = sourceAiDiv.getAttribute( + BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, + ); + if (fingerprint) { + tooltipAiDiv.setAttribute( + BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, + fingerprint, + ); + } else { + tooltipAiDiv.removeAttribute( + BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, + ); + } + + const pendingFingerprint = sourceAiDiv.getAttribute( + BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, + ); + if (pendingFingerprint) { + tooltipAiDiv.setAttribute( + BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, + pendingFingerprint, + ); + } else { + tooltipAiDiv.removeAttribute( + BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, + ); + } + + const requestToken = sourceAiDiv.getAttribute( + BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, + ); + if (requestToken) { + tooltipAiDiv.setAttribute( + BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, + requestToken, + ); + } else { + tooltipAiDiv.removeAttribute( + BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, + ); + } + + tooltipAiDiv.innerText = sourceAiDiv.innerText; + }); + } + + private static maybeRememberSourceBubbleLanguage(langTag: string): void { + if (BloomSourceBubbles.isAiLanguageTag(langTag)) { + return; + } + + postString("editView/sourceTextTab", langTag); + } + + private static getExistingAiSourceBubbleDiv( + group: JQuery, + aiLanguageTag: string, + ): HTMLDivElement | undefined { + return group.find(`div[lang='${aiLanguageTag}']`).first().get(0) as + | HTMLDivElement + | undefined; + } + + private static ensureAiSourceBubbleDiv( + group: JQuery, + aiLanguageTag: string, + ): HTMLDivElement { + const existingDiv = BloomSourceBubbles.getExistingAiSourceBubbleDiv( + group, + aiLanguageTag, + ); + if (existingDiv) { + existingDiv.classList.add( + "bloom-editable", + BloomSourceBubbles.kAiSourceBubbleClass, + ); + existingDiv.setAttribute("lang", aiLanguageTag); + existingDiv.setAttribute("contenteditable", "true"); + return existingDiv; + } + + const aiDiv = document.createElement("div"); + aiDiv.className = `bloom-editable ${BloomSourceBubbles.kAiSourceBubbleClass}`; + aiDiv.setAttribute("lang", aiLanguageTag); + aiDiv.setAttribute("contenteditable", "true"); + group.append(aiDiv); + return aiDiv; + } + + private static isCurrentAiSourceBubbleTranslation( + aiDiv: HTMLDivElement, + fingerprint: string, + ): boolean { + return ( + aiDiv.getAttribute( + BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, + ) === fingerprint && !BloomSourceBubbles.hasNoText(aiDiv) + ); + } + + private static async wrapAiSourceBubbleUpdateWithPageContentDelay( + fn: () => Promise, + delayId: string, + ): Promise { + const editablePageBundle = getEditablePageBundleExports(); + if (!editablePageBundle) { + return fn(); + } + + editablePageBundle.addRequestPageContentDelay(delayId); + try { + const result = await fn(); + editablePageBundle.removeRequestPageContentDelay(delayId); + return result; + } catch (error) { + editablePageBundle.removeRequestPageContentDelay(delayId); + throw error; + } + } + + private static ensureAiSourceBubbleTranslation( + group: HTMLElement, + sourceText: string, + sourceLanguageTag: string, + aiLanguageTag: string, + ): void { + const $group = $(group); + BloomSourceBubbles.removeAiSourceBubbleDivs($group, aiLanguageTag); + const aiDiv = BloomSourceBubbles.ensureAiSourceBubbleDiv( + $group, + aiLanguageTag, + ); + const fingerprint = BloomSourceBubbles.getAiSourceBubbleFingerprint( + sourceText, + sourceLanguageTag, + aiLanguageTag, + ); + + if ( + BloomSourceBubbles.isCurrentAiSourceBubbleTranslation( + aiDiv, + fingerprint, + ) + ) { + aiDiv.removeAttribute( + BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, + ); + aiDiv.removeAttribute( + BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, + ); + BloomSourceBubbles.syncVisibleAiSourceBubble(group, aiDiv); + return; + } + + if ( + aiDiv.getAttribute( + BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, + ) === fingerprint + ) { + BloomSourceBubbles.syncVisibleAiSourceBubble(group, aiDiv); + return; + } + + const requestToken = + (++BloomSourceBubbles.nextAiSourceBubbleRequestToken).toString(); + aiDiv.setAttribute("lang", aiLanguageTag); + aiDiv.setAttribute( + BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, + fingerprint, + ); + aiDiv.setAttribute( + BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, + requestToken, + ); + aiDiv.removeAttribute( + BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, + ); + aiDiv.innerText = "Translating..."; + BloomSourceBubbles.syncVisibleAiSourceBubble(group, aiDiv); + + const delayId = `ai-source-bubble:${requestToken}`; + void BloomSourceBubbles.wrapAiSourceBubbleUpdateWithPageContentDelay( + async () => { + try { + const response = + await BloomSourceBubbles.translateSourceBubbleAsync( + sourceText, + sourceLanguageTag, + ); + if ( + aiDiv.getAttribute( + BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, + ) !== requestToken + ) { + return; + } + + const responseLanguageTag = + response?.aiLanguageTag || aiLanguageTag; + aiDiv.setAttribute("lang", responseLanguageTag); + aiDiv.innerText = + response?.text || "No translation returned."; + aiDiv.setAttribute( + BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, + BloomSourceBubbles.getAiSourceBubbleFingerprint( + sourceText, + sourceLanguageTag, + responseLanguageTag, + ), + ); + BloomSourceBubbles.syncVisibleAiSourceBubble(group, aiDiv); + } catch (error) { + if ( + aiDiv.getAttribute( + BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, + ) !== requestToken + ) { + return; + } + + aiDiv.innerText = + BloomSourceBubbles.getTranslationErrorMessage(error); + aiDiv.removeAttribute( + BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, + ); + BloomSourceBubbles.syncVisibleAiSourceBubble(group, aiDiv); + } finally { + if ( + aiDiv.getAttribute( + BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, + ) === requestToken + ) { + aiDiv.removeAttribute( + BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, + ); + aiDiv.removeAttribute( + BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, + ); + BloomSourceBubbles.syncVisibleAiSourceBubble( + group, + aiDiv, + ); + } + } + }, + delayId, + ); + } + + private static async translateSourceBubbleAsync( + sourceText: string, + sourceLanguageTag: string, + ): Promise<{ text?: string; aiLanguageTag?: string } | undefined> { + const response = await postJsonAsync("aiSourceBubbles/translate", { + sourceText, + sourceLanguageTag, + }); + const data = response?.data as + | { + text?: string; + aiLanguageTag?: string; + Text?: string; + AiLanguageTag?: string; + } + | undefined; + if (!data) { + return undefined; + } + + return { + text: data.text || data.Text, + aiLanguageTag: data.aiLanguageTag || data.AiLanguageTag, + }; + } + + private static getTranslationErrorMessage(error: unknown): string { + if (typeof error === "string") { + return error; + } + + if (typeof error === "object" && error !== null) { + const errorInfo = error as { + response?: { data?: string }; + message?: string; + }; + return ( + errorInfo.response?.data || + errorInfo.message || + "Translation failed." + ); + } + + return "Translation failed."; + } + + private static getLanguageDisplayName(langTag: string): string { + const aiSplitMarker = "-x-ai-"; + const aiSplitIndex = langTag.indexOf(aiSplitMarker); + if (aiSplitIndex < 0) { + return ( + theOneLocalizationManager.getLanguageName(langTag) || langTag + ); + } + + const targetLanguageTag = langTag.substring(0, aiSplitIndex); + const providerId = langTag.substring( + aiSplitIndex + aiSplitMarker.length, + ); + const targetLanguageName = + theOneLocalizationManager.getLanguageName(targetLanguageTag) || + targetLanguageTag; + return `AI ${targetLanguageName}`; + } + + private static appendSourceTabLabel( + anchor: HTMLAnchorElement, + langTag: string, + localizedLanguageName: string, + ): void { + if (!BloomSourceBubbles.isAiLanguageTag(langTag)) { + anchor.textContent = localizedLanguageName; + return; + } + + ReactDOM.render( + <> + + {localizedLanguageName} + , + anchor, + ); + } + // This is the method that should be called from bloomEditing to create tabbed source bubbles // for translation. // param 'group' is a .bloom-translationGroup DIV @@ -95,9 +559,29 @@ export default class BloomSourceBubbles { newLangTag?: string, ): JQuery { if (group.classList.contains("bloom-no-source-bubble")) return $(); + const liveGroup = $(group); + const sourceDiv = BloomSourceBubbles.getPreferredSourceDiv(liveGroup); + const aiSourceBubbleLangTag = + BloomSourceBubbles.getAiSourceBubbleLangTag(); + const sourceLanguageTag = sourceDiv.attr("lang"); + if ( + aiSourceBubbleLangTag && + sourceDiv.length > 0 && + sourceLanguageTag + ) { + BloomSourceBubbles.ensureAiSourceBubbleTranslation( + group, + sourceDiv.text(), + sourceLanguageTag, + aiSourceBubbleLangTag, + ); + } else { + BloomSourceBubbles.removeAiSourceBubbleDivs(liveGroup); + } + // Copy source texts out to their own div, where we can make a bubble with tabs out of them // We do this because if we made a bubble out of the div, that would suck up the vernacular editable area, too, - const divForBubble = $(group).clone(); + const divForBubble = liveGroup.clone(); divForBubble.removeAttr("style"); divForBubble.removeClass(); //remove them all divForBubble.addClass("ui-sourceTextsForBubble"); @@ -105,41 +589,11 @@ export default class BloomSourceBubbles { divForBubble.find("label.bubble").each((index, element) => { $(element).remove(); }); + BloomSourceBubbles.removeAiSourceBubbleDivs( + divForBubble, + aiSourceBubbleLangTag, + ); - // get the first div that has a lang attribute of either "en" or "es" - const sourceDiv = divForBubble.find("div[lang='en']").first(); - if (sourceDiv.length > 0 && sourceDiv.text().length > 0) { - const targetDiv = document.createElement("div"); - targetDiv.setAttribute("lang", "fr-x-ai"); - targetDiv.className = "bloom-editable source-text"; - targetDiv.innerText = "waiting"; - divForBubble.append(targetDiv); - // Now make a call to deepl api to translate that into French. When the call returns, set the text of that div to the French translation. - axios - .post( - "https://api-free.deepl.com/v2/translate", - { - text: [sourceDiv.text()], - source_lang: sourceDiv.attr("lang"), - target_lang: "FR" - }, - { - headers: { - Authorization: - "DeepL-Auth-Key ", - "Content-Type": "application/json" - } - } - ) - .then(response => { - targetDiv.innerText = response.data.translations[0].text; - }) - .catch(error => { - targetDiv.innerText = "error " + error; - }); - } - - // make a call to deepl api to translate "bonjour le monde" into English. Then //make the source texts in the bubble read-only and remove any user font size adjustments divForBubble.find("textarea, div").each(function (): boolean { //don't want empty items in the bubble @@ -207,8 +661,7 @@ export default class BloomSourceBubbles { const langTag = sourceElement.getAttribute("lang"); if (langTag) { const localizedLanguageName = - theOneLocalizationManager.getLanguageName(langTag) || - langTag; + BloomSourceBubbles.getLanguageDisplayName(langTag); // This is bizarre. The href ought to be referring to the element with the specified ID, // which should be the tab CONTENT that should be shown for this language. But we have modified // easytabs (see above) so that the target (main page content div) for a tab is the element whose @@ -216,22 +669,27 @@ export default class BloomSourceBubbles { // Even more bizarrely, we make the id of the list item have that value also, so that // the apparent target of the is the
  • it resides inside. Not sure why this is // helpful. - $(list).append( - '
  • ' + - localizedLanguageName + - "
  • ", + const liElement = document.createElement("li"); + liElement.id = langTag; + liElement.title = langTag; + const anchor = document.createElement("a"); + anchor.className = "sourceTextTab"; + anchor.href = `#${langTag}`; + BloomSourceBubbles.appendSourceTabLabel( + anchor, + langTag, + localizedLanguageName, ); + liElement.append(anchor); + list.append(liElement); ( list.get(0) as HTMLElement ).lastElementChild?.firstElementChild?.addEventListener( "click", - () => postString("editView/sourceTextTab", langTag), + () => + BloomSourceBubbles.maybeRememberSourceBubbleLanguage( + langTag, + ), ); // BL-8174: Add a tooltip with the language tag to the item // BL-15212: we no longer want the tag here, just on the language-name label @@ -339,7 +797,7 @@ export default class BloomSourceBubbles { if (indexA >= 0) return -1; if (indexB >= 0) return 1; // Neither in preferred list - maintain alphabetical order - return langA < langB ? (langA > langB ? 1 : 0) : -1; + return langA < langB ? -1 : langA > langB ? 1 : 0; }); return $(itemArray); @@ -477,7 +935,7 @@ export default class BloomSourceBubbles { group[0], newLangTag, ); - postString("editView/sourceTextTab", newLangTag); + BloomSourceBubbles.maybeRememberSourceBubbleLanguage(newLangTag); if (divForBubble.length !== 0) { BloomHintBubbles.addHintBubbles( group.get(0), diff --git a/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts b/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts index e3f31a24e80e..ddcd0fef76b4 100644 --- a/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts +++ b/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts @@ -1,16 +1,38 @@ /// /// -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import $ from "jquery"; + +vi.mock("../../utils/bloomApi", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + postJson: vi.fn(), + postJsonAsync: vi.fn(), + postString: vi.fn(), + }; +}); + +import { postJsonAsync, postString } from "../../utils/bloomApi"; import BloomSourceBubbles from "./BloomSourceBubbles"; +const mockedPostJsonAsync = vi.mocked(postJsonAsync); +const mockedPostString = vi.mocked(postString); + describe("SourceBubbles", () => { + const originalGetSettings = (window as any).GetSettings; + // reset fixture beforeEach(() => { $("body").html(""); + mockedPostJsonAsync.mockReset(); + mockedPostJsonAsync.mockResolvedValue(undefined); + mockedPostString.mockReset(); }); afterEach(() => { $("body").html(""); + (window as any).GetSettings = originalGetSettings; }); it("Run MakeSourceTextDivForGroup with pre-defined settings", () => { // TODO: Testing is a bit hampered by not being able (currently) to put test values @@ -243,6 +265,40 @@ describe("SourceBubbles", () => { expect(listItems[1].getAttribute("id")).toBe("es"); // alphabetical }); + it("MakeSourceTextDivForGroup keeps non-preferred languages in alphabetical order", () => { + const testHtml = $( + [ + "
    ", + "
    English text
    ", + "
    Swahili text
    ", + "
    French text
    ", + "
    Spanish text
    ", + "
    ", + ].join("\n"), + ); + $("body").append(testHtml); + + const oldGetSettings = (window as any).GetSettings; + (window as any).GetSettings = () => ({ + defaultSourceLanguage: "", + defaultSourceLanguage2: "", + currentCollectionLanguage2: "", + currentCollectionLanguage3: "", + }); + + const result = BloomSourceBubbles.MakeSourceTextDivForGroup( + $("body").find("#testTarget")[0], + ); + + (window as any).GetSettings = oldGetSettings; + + const listItems = result.find("nav ul li"); + expect(listItems.length).toBe(3); + expect(listItems[0].getAttribute("id")).toBe("es"); + expect(listItems[1].getAttribute("id")).toBe("fr"); + expect(listItems[2].getAttribute("id")).toBe("sw"); + }); + it("MakeSourceTextDivForGroup handles when defaultSourceLanguage2 equals defaultSourceLanguage", () => { const testHtml = $( [ @@ -278,4 +334,255 @@ describe("SourceBubbles", () => { expect(listItems[1].getAttribute("id")).toBe("tpi"); // collection lang 2 expect(listItems[2].getAttribute("id")).toBe("es"); // alphabetical }); + + it("MakeSourceTextDivForGroup reuses a current AI translation stored in the live group", () => { + const sourceText = "English text"; + const aiLanguageTag = "id-x-ai-google"; + const fingerprint = ( + BloomSourceBubbles as any + ).getAiSourceBubbleFingerprint(sourceText, "en", aiLanguageTag); + const testHtml = $( + [ + "
    ", + "
    English text
    ", + `
    Teks Indonesia
    `, + "
    ", + ].join("\n"), + ); + $("body").append(testHtml); + (window as any).GetSettings = () => ({ + defaultSourceLanguage: "en", + defaultSourceLanguage2: "", + currentCollectionLanguage2: "", + currentCollectionLanguage3: "", + allowAiSourceBubbles: true, + aiSourceBubblesLanguageTag: aiLanguageTag, + }); + + const result = BloomSourceBubbles.MakeSourceTextDivForGroup( + $("body").find("#testTarget")[0], + ); + + expect(mockedPostJsonAsync).not.toHaveBeenCalled(); + expect( + result.find(`div.source-text[lang='${aiLanguageTag}']`)[0] + .childNodes[0].textContent, + ).toBe("Teks Indonesia"); + }); + + it("MakeSourceTextDivForGroup shows an icon before AI language tab labels", () => { + const testHtml = $( + [ + "
    ", + "
    English text
    ", + "
    French text
    ", + "
    Bulan dan Topi
    ", + "
    ", + ].join("\n"), + ); + $("body").append(testHtml); + + (window as any).GetSettings = () => ({ + defaultSourceLanguage: "en", + defaultSourceLanguage2: "", + currentCollectionLanguage2: "", + currentCollectionLanguage3: "", + allowAiSourceBubbles: true, + aiSourceBubblesLanguageTag: "id-x-ai-deepl", + }); + + const result = BloomSourceBubbles.MakeSourceTextDivForGroup( + $("body").find("#testTarget")[0], + ); + + const aiTab = result.find("li#id-x-ai-deepl a.sourceTextTab"); + expect(aiTab.length).toBe(1); + expect(aiTab.find("svg[data-testid='AutoAwesomeIcon']").length).toBe(1); + expect(aiTab.text()).toContain("AI "); + + const nonAiTab = result.find("li#fr a.sourceTextTab"); + expect(nonAiTab.find("svg[data-testid='AutoAwesomeIcon']").length).toBe( + 0, + ); + }); + + it("MakeSourceTextDivForGroup does not start a second request while the same AI translation is pending", () => { + const aiLanguageTag = "id-x-ai-google"; + mockedPostJsonAsync.mockImplementation( + () => new Promise(() => undefined), + ); + const testHtml = $( + [ + "
    ", + "
    English text
    ", + "
    ", + ].join("\n"), + ); + $("body").append(testHtml); + (window as any).GetSettings = () => ({ + defaultSourceLanguage: "en", + defaultSourceLanguage2: "", + currentCollectionLanguage2: "", + currentCollectionLanguage3: "", + allowAiSourceBubbles: true, + aiSourceBubblesLanguageTag: aiLanguageTag, + }); + + const firstResult = BloomSourceBubbles.MakeSourceTextDivForGroup( + $("body").find("#testTarget")[0], + ); + const secondResult = BloomSourceBubbles.MakeSourceTextDivForGroup( + $("body").find("#testTarget")[0], + ); + + expect(mockedPostJsonAsync).toHaveBeenCalledTimes(1); + expect( + firstResult.find(`div.source-text[lang='${aiLanguageTag}']`)[0] + .childNodes[0].textContent, + ).toBe("Translating..."); + expect( + secondResult.find(`div.source-text[lang='${aiLanguageTag}']`)[0] + .childNodes[0].textContent, + ).toBe("Translating..."); + }); + + it("MakeSourceTextDivForGroup ignores an AI default source language when choosing text to translate", () => { + const aiLanguageTag = "id-x-ai-deepl"; + mockedPostJsonAsync.mockImplementation( + () => new Promise(() => undefined), + ); + const testHtml = $( + [ + "
    ", + "
    English text
    ", + "
    Tok Pisin text
    ", + `
    Old Indonesian
    `, + "
    ", + ].join("\n"), + ); + $("body").append(testHtml); + (window as any).GetSettings = () => ({ + defaultSourceLanguage: aiLanguageTag, + defaultSourceLanguage2: "", + currentCollectionLanguage2: "tpi", + currentCollectionLanguage3: "", + allowAiSourceBubbles: true, + aiSourceBubblesLanguageTag: aiLanguageTag, + }); + + BloomSourceBubbles.MakeSourceTextDivForGroup( + $("body").find("#testTarget")[0], + ); + + expect(mockedPostJsonAsync).toHaveBeenCalledTimes(1); + expect(mockedPostJsonAsync.mock.calls[0][0]).toBe( + "aiSourceBubbles/translate", + ); + expect(mockedPostJsonAsync.mock.calls[0][1]).toEqual({ + sourceText: "Tok Pisin text", + sourceLanguageTag: "tpi", + }); + }); + + it("AI source bubble tabs are not remembered as the default source language", () => { + const aiLanguageTag = "id-x-ai-deepl"; + const sourceText = "Tok Pisin text"; + const fingerprint = ( + BloomSourceBubbles as any + ).getAiSourceBubbleFingerprint(sourceText, "tpi", aiLanguageTag); + const testHtml = $( + [ + "
    ", + "
    Tok Pisin text
    ", + `
    Bahasa Indonesia
    `, + "
    ", + ].join("\n"), + ); + $("body").append(testHtml); + (window as any).GetSettings = () => ({ + defaultSourceLanguage: "tpi", + defaultSourceLanguage2: "", + currentCollectionLanguage2: "", + currentCollectionLanguage3: "", + allowAiSourceBubbles: true, + aiSourceBubblesLanguageTag: aiLanguageTag, + }); + + const result = BloomSourceBubbles.MakeSourceTextDivForGroup( + $("body").find("#testTarget")[0], + ); + + const aiTab = result.find(`li#${CSS.escape(aiLanguageTag)} a`); + expect(aiTab.length).toBe(1); + aiTab.get(0)?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(mockedPostString).not.toHaveBeenCalledWith( + "editView/sourceTextTab", + aiLanguageTag, + ); + }); + + it("translateSourceBubbleAsync maps PascalCase API response properties", async () => { + mockedPostJsonAsync.mockResolvedValue({ + data: { + Text: "Bahasa Indonesia", + AiLanguageTag: "id-x-ai-deepl", + }, + } as any); + + const response = await ( + BloomSourceBubbles as any + ).translateSourceBubbleAsync("Tok Pisin text", "tpi"); + + expect(response).toEqual({ + text: "Bahasa Indonesia", + aiLanguageTag: "id-x-ai-deepl", + }); + }); + + it("MakeSourceTextDivForGroup syncs a stale visible qtip when the AI translation is already current", () => { + const aiLanguageTag = "id-x-ai-deepl"; + const sourceText = "The Moon and the Cap"; + const fingerprint = ( + BloomSourceBubbles as any + ).getAiSourceBubbleFingerprint(sourceText, "en", aiLanguageTag); + const testHtml = $( + [ + "
    ", + `
    ${sourceText}
    `, + `
    Bulan dan Topi
    `, + "
    ", + "
    ", + `
    Translating...
    `, + "
    ", + ].join("\n"), + ); + $("body").append(testHtml); + (window as any).GetSettings = () => ({ + defaultSourceLanguage: "en", + defaultSourceLanguage2: "", + currentCollectionLanguage2: "", + currentCollectionLanguage3: "", + allowAiSourceBubbles: true, + aiSourceBubblesLanguageTag: aiLanguageTag, + }); + + BloomSourceBubbles.MakeSourceTextDivForGroup( + $("body").find("#testTarget")[0], + ); + + const tooltipAiDiv = $("body").find( + "#qtip-0 .bloom-ai-source-bubble-translation", + ); + expect(tooltipAiDiv.text()).toBe("Bulan dan Topi"); + expect(tooltipAiDiv.attr("data-ai-source-bubble-fingerprint")).toBe( + fingerprint, + ); + expect( + tooltipAiDiv.attr("data-ai-source-bubble-pending-fingerprint"), + ).toBeUndefined(); + expect( + tooltipAiDiv.attr("data-ai-source-bubble-request-token"), + ).toBeUndefined(); + }); }); diff --git a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx index 4e3c6b4a8255..5c0d6d082533 100644 --- a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx +++ b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx @@ -2,28 +2,71 @@ import { css } from "@emotion/react"; import * as React from "react"; import { ConfigrBoolean, + ConfigrCustomObjectInput, ConfigrGroup, ConfigrInput, ConfigrPage, ConfigrPane, + ConfigrSelect, } from "@sillsdev/config-r"; +import { + defaultDisplayName, + parseLangtagFromLangChooser, +} from "@ethnolib/language-chooser-react-mui"; +import { MenuItem, TextField } from "@mui/material"; import { get, postJson } from "../utils/bloomApi"; import { kBloomBlue } from "../bloomMaterialUITheme"; import { tabMargins } from "./commonTabSettings"; +import { + ILanguageData, + showLanguageChooserDialog, +} from "./LanguageChooserDialog"; import { WireUpForWinforms } from "../utils/WireUpWinform"; import { BloomSubscriptionIndicatorIconAndText } from "../react_components/requiresSubscription"; import { useGetFeatureStatus } from "../react_components/featureStatus"; import { useL10n } from "../react_components/l10nHooks"; +const kOtherTargetLanguageValue = "__other__"; + +interface ITargetLanguageOption { + value: string; + label: string; +} + +interface IAdvancedSettingsApiData { + values: IAdvancedSettings; + showAutoUpdate?: boolean; + showExperimentalBookSourcesOption?: boolean; + allowTeamCollectionEnabled?: boolean; + aiSourceBubblesKnownTargetLanguages?: ITargetLanguageOption[]; +} + interface IAdvancedSettings { autoUpdate?: boolean; showExperimentalBookSources?: boolean; allowTeamCollection?: boolean; allowAppBuilder?: boolean; + allowAiSourceBubbles?: boolean; + aiSourceBubblesProvider?: string; + aiSourceBubblesTargetLanguageTag?: string; + aiSourceBubblesDeepLApiKey?: string; + aiSourceBubblesGoogleServiceAccountEmail?: string; + aiSourceBubblesGooglePrivateKey?: string; showQrCode?: boolean; qrcodeCaption?: string; } +function getLanguageOptionLabel(languageTag: string): string { + const parsedLanguage = parseLangtagFromLangChooser(languageTag); + const nameInScript = parsedLanguage?.script?.languageNameInScript; + const defaultName = + nameInScript || + (parsedLanguage?.language + ? defaultDisplayName(parsedLanguage.language) + : undefined); + return defaultName || languageTag; +} + export const AdvancedSettingsPanel: React.FunctionComponent = () => { const [settings, setSettings] = React.useState< IAdvancedSettings | undefined @@ -31,6 +74,14 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { const [showAutoUpdate, setShowAutoUpdate] = React.useState(false); const [allowTeamCollectionEnabled, setAllowTeamCollectionEnabled] = React.useState(false); + const [ + aiSourceBubblesKnownTargetLanguages, + setAiSourceBubblesKnownTargetLanguages, + ] = React.useState([]); + const [ + aiSourceBubblesCustomTargetLanguage, + setAiSourceBubblesCustomTargetLanguage, + ] = React.useState(); const [ showExperimentalBookSourcesOption, setShowExperimentalBookSourcesOption, @@ -64,6 +115,42 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { "App Builder", "CollectionSettingsDialog.AdvancedTab.Experimental.AppBuilder", ); + const aiSourceBubblesLabel = useL10n( + "AI Source Bubbles", + "CollectionSettingsDialog.AdvancedTab.Experimental.AiSourceBubbles", + ); + const aiSourceBubblesSectionLabel = useL10n( + "AI Source Bubbles", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.SectionLabel", + ); + const aiSourceBubblesProviderLabel = useL10n( + "Provider", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.ProviderLabel", + ); + const aiSourceBubblesTargetLanguageLabel = useL10n( + "Target Language", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TargetLanguageLabel", + ); + const aiSourceBubblesTargetLanguageDescription = useL10n( + "Choose one of this collection's languages, or Other... to select another language.", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TargetLanguageDescription", + ); + const aiSourceBubblesOtherLanguageLabel = useL10n( + "Other...", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.OtherLanguageLabel", + ); + const aiSourceBubblesDeepLApiKeyLabel = useL10n( + "DeepL API Key", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLApiKeyLabel", + ); + const aiSourceBubblesGoogleServiceAccountEmailLabel = useL10n( + "Google Service Account Email", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GoogleServiceAccountEmailLabel", + ); + const aiSourceBubblesGooglePrivateKeyLabel = useL10n( + "Google Service Account Private Key", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GooglePrivateKeyLabel", + ); const qrCodesLabel = useL10n( "QR Codes", "CollectionSettingsDialog.AdvancedTab.QrCodes", @@ -93,6 +180,11 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { appBuilderFeatureStatus === undefined ? false : appBuilderFeatureStatus.enabled; + const aiSourceBubblesFeatureStatus = useGetFeatureStatus("AiSourceBubbles"); + const aiSourceBubblesOptionEnabled = + aiSourceBubblesFeatureStatus === undefined + ? false + : aiSourceBubblesFeatureStatus.enabled; const canChangeTeamCollectionOption = allowTeamCollectionEnabled !== false; const normalizeConfigrSettings = React.useCallback( @@ -110,6 +202,101 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { [], ); + const makeCustomTargetLanguageOption = React.useCallback( + (languageTag: string, displayName?: string): ITargetLanguageOption => { + const label = displayName || getLanguageOptionLabel(languageTag); + return { + value: languageTag, + label: `${label} (${languageTag})`, + }; + }, + [], + ); + + const getAiSourceBubblesTargetLanguageOptions = React.useCallback(() => { + const options = [...aiSourceBubblesKnownTargetLanguages]; + const selectedTargetLanguageTag = + settings?.aiSourceBubblesTargetLanguageTag?.trim(); + if ( + selectedTargetLanguageTag && + !options.some( + (option) => option.value === selectedTargetLanguageTag, + ) + ) { + const customOption = + aiSourceBubblesCustomTargetLanguage?.value === + selectedTargetLanguageTag + ? aiSourceBubblesCustomTargetLanguage + : makeCustomTargetLanguageOption(selectedTargetLanguageTag); + options.push(customOption); + } + + options.push({ + value: kOtherTargetLanguageValue, + label: aiSourceBubblesOtherLanguageLabel, + }); + + return options; + }, [ + aiSourceBubblesCustomTargetLanguage, + aiSourceBubblesKnownTargetLanguages, + aiSourceBubblesOtherLanguageLabel, + makeCustomTargetLanguageOption, + settings?.aiSourceBubblesTargetLanguageTag, + ]); + + const AiSourceBubblesTargetLanguageControl: React.FunctionComponent<{ + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }> = (props) => { + return ( + { + const nextValue = event.target.value; + if (nextValue === kOtherTargetLanguageValue) { + const selectedTargetLanguageTag = + props.value || undefined; + showLanguageChooserDialog( + selectedTargetLanguageTag, + undefined, + (languageData: ILanguageData) => { + if (!languageData.LanguageTag) { + return; + } + + setAiSourceBubblesCustomTargetLanguage( + makeCustomTargetLanguageOption( + languageData.LanguageTag, + languageData.DesiredName || + languageData.DefaultName || + undefined, + ), + ); + props.onChange(languageData.LanguageTag); + }, + ); + return; + } + + props.onChange(nextValue); + }} + > + + {getAiSourceBubblesTargetLanguageOptions().map((option) => ( + + {option.label} + + ))} + + ); + }; + // Load current advanced settings from the host dialog so Config-r starts with matching values. React.useEffect(() => { get("settings/advancedProgramSettings", (result) => { @@ -120,16 +307,38 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { if (typeof result.data === "string") { data = JSON.parse(result.data); } - setSettings(data["values"]); - setShowAutoUpdate(data["showAutoUpdate"] ?? false); + const advancedSettingsData = data as IAdvancedSettingsApiData; + const loadedSettings = advancedSettingsData.values; + setSettings(loadedSettings); + setShowAutoUpdate(advancedSettingsData.showAutoUpdate ?? false); setAllowTeamCollectionEnabled( - data["allowTeamCollectionEnabled"] ?? false, + advancedSettingsData.allowTeamCollectionEnabled ?? false, ); setShowExperimentalBookSourcesOption( - data["showExperimentalBookSourcesOption"] ?? false, + advancedSettingsData.showExperimentalBookSourcesOption ?? false, + ); + setAiSourceBubblesKnownTargetLanguages( + advancedSettingsData.aiSourceBubblesKnownTargetLanguages ?? [], ); + if ( + loadedSettings?.aiSourceBubblesTargetLanguageTag && + !( + advancedSettingsData.aiSourceBubblesKnownTargetLanguages ?? + [] + ).some( + (option) => + option.value === + loadedSettings.aiSourceBubblesTargetLanguageTag, + ) + ) { + setAiSourceBubblesCustomTargetLanguage( + makeCustomTargetLanguageOption( + loadedSettings.aiSourceBubblesTargetLanguageTag, + ), + ); + } }); - }, []); + }, [makeCustomTargetLanguageOption]); return (
    { />
    +
    + +
    + +
    +
    + {settings.allowAiSourceBubbles && ( + + + + path="aiSourceBubblesTargetLanguageTag" + control={ + AiSourceBubblesTargetLanguageControl + } + label={aiSourceBubblesTargetLanguageLabel} + description={ + aiSourceBubblesTargetLanguageDescription + } + /> + {settings.aiSourceBubblesProvider === + "deepl" && ( + + )} + {settings.aiSourceBubblesProvider === + "google" && ( + <> + + + + )} + + )} )} diff --git a/src/BloomBrowserUI/collection/LanguageChooserDialog.tsx b/src/BloomBrowserUI/collection/LanguageChooserDialog.tsx index ac5810ce6eef..99a75e1df468 100644 --- a/src/BloomBrowserUI/collection/LanguageChooserDialog.tsx +++ b/src/BloomBrowserUI/collection/LanguageChooserDialog.tsx @@ -61,6 +61,7 @@ export function getLanguageData( export const LanguageChooserDialog: React.FunctionComponent<{ initialLanguageTag?: string; initialCustomName?: string; + onOk?: (languageData: ILanguageData) => void; dialogEnvironment?: IBloomDialogEnvironmentParams; }> = (props) => { const { showDialog, closeDialog, propsForBloomDialog } = @@ -108,10 +109,12 @@ export const LanguageChooserDialog: React.FunctionComponent<{ ); function onOk(languageSelection: IOrthography, languageTag: string) { - postData( - "settings/changeLanguage", - getLanguageData(languageTag, languageSelection), - ); + const languageData = getLanguageData(languageTag, languageSelection); + if (props.onOk) { + props.onOk(languageData); + } else { + postData("settings/changeLanguage", languageData); + } closeDialog(); } @@ -125,8 +128,12 @@ export const LanguageChooserDialog: React.FunctionComponent<{ return ( - +
    + +
    ); }; @@ -174,12 +193,14 @@ let show: () => void = () => { export function showLanguageChooserDialog( initialLanguageTag?: string, initialCustomName?: string, + onOk?: (languageData: ILanguageData) => void, ) { try { renderRootSync( , getModalContainer(), ); diff --git a/src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs b/src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs new file mode 100644 index 000000000000..986a592af2d1 --- /dev/null +++ b/src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs @@ -0,0 +1,473 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Bloom.Collection; +using Bloom.SubscriptionAndFeatures; +using Bloom.Utils; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SIL.WritingSystems; + +namespace Bloom.AiSourceBubbles +{ + /// + /// Request payload for translating a single source bubble. + /// + public class AiSourceBubblesTranslateRequest + { + public string SourceText { get; set; } + public string SourceLanguageTag { get; set; } + } + + /// + /// Response payload for translating a single source bubble. + /// + public class AiSourceBubblesTranslateResponse + { + public string ProviderId { get; set; } + public string TargetLanguageTag { get; set; } + public string AiLanguageTag { get; set; } + public string Text { get; set; } + } + + /// + /// Coordinates collection-backed AI Source Bubbles translation. + /// + public class AiSourceBubblesService + { + private static readonly HttpClient _httpClient = new HttpClient(); + private readonly CollectionSettings _collectionSettings; + private readonly Dictionary _providers; + + public AiSourceBubblesService(CollectionSettings collectionSettings) + { + _collectionSettings = collectionSettings; + _providers = new Dictionary( + StringComparer.OrdinalIgnoreCase + ) + { + { "deepl", new DeepLAiSourceBubblesTranslationProvider() }, + { "google", new GoogleAiSourceBubblesTranslationProvider() }, + }; + } + + /// + /// Translates one source-bubble text block using the provider configured on the current collection. + /// + public async Task TranslateAsync( + AiSourceBubblesTranslateRequest request + ) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.SourceText)) + throw new ArgumentException("Source text is required.", nameof(request)); + + var featureStatus = FeatureStatus.GetFeatureStatus( + _collectionSettings.Subscription, + FeatureName.AiSourceBubbles + ); + if (!featureStatus.Visible || !featureStatus.Enabled) + { + throw new InvalidOperationException( + "AI Source Bubbles is not enabled for this collection." + ); + } + + var provider = GetSelectedProvider(); + var targetLanguageTag = NormalizeBloomLanguageTag( + _collectionSettings.AiSourceBubblesTargetLanguageTag + ); + if (string.IsNullOrWhiteSpace(targetLanguageTag)) + { + throw new InvalidOperationException( + "Set a target language tag in Collection Settings > AI Source Bubbles." + ); + } + + var translatedText = await provider.TranslateAsync( + _collectionSettings, + request.SourceText, + request.SourceLanguageTag, + targetLanguageTag, + _httpClient + ); + + return new AiSourceBubblesTranslateResponse + { + ProviderId = provider.ProviderId, + TargetLanguageTag = targetLanguageTag, + AiLanguageTag = GetAiLanguageTag(targetLanguageTag, provider.ProviderId), + Text = translatedText, + }; + } + + /// + /// Builds the Bloom language tag used for AI content in a translation group. + /// + public static string GetAiLanguageTag(string targetLanguageTag, string providerId) + { + var normalizedTarget = NormalizeBloomLanguageTag(targetLanguageTag); + var normalizedProvider = NormalizeProviderId(providerId); + if ( + string.IsNullOrWhiteSpace(normalizedTarget) + || string.IsNullOrWhiteSpace(normalizedProvider) + ) + { + return string.Empty; + } + + return $"{normalizedTarget}-x-ai-{normalizedProvider}"; + } + + /// + /// Normalizes the provider id used in settings, API payloads, and AI language tags. + /// + public static string NormalizeProviderId(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + return string.Empty; + + var normalized = providerId.Trim().ToLowerInvariant(); + return normalized == "googletranslate" ? "google" : normalized; + } + + /// + /// Normalizes a Bloom language tag for provider requests while preserving region when available. + /// + public static string NormalizeBloomLanguageTag(string languageTag) + { + if (string.IsNullOrWhiteSpace(languageTag)) + return string.Empty; + + var trimmed = languageTag.Trim(); + var privateUseIndex = trimmed.IndexOf("-x-", StringComparison.OrdinalIgnoreCase); + if (privateUseIndex >= 0) + { + trimmed = trimmed.Substring(0, privateUseIndex); + } + + trimmed = MiscUtils.NormalizeLanguageTagCapitalization(trimmed); + if ( + !IetfLanguageTag.TryGetParts( + trimmed, + out var language, + out var script, + out var region, + out var variant + ) || string.IsNullOrWhiteSpace(language) + ) + { + return trimmed; + } + + language = language.ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(region)) + { + return $"{language}-{region.ToUpperInvariant()}"; + } + + return language; + } + + private IAiSourceBubblesTranslationProvider GetSelectedProvider() + { + var providerId = NormalizeProviderId(_collectionSettings.AiSourceBubblesProviderId); + if (string.IsNullOrWhiteSpace(providerId)) + { + throw new InvalidOperationException( + "Select an AI Source Bubbles provider in Collection Settings." + ); + } + + if (!_providers.TryGetValue(providerId, out var provider)) + { + throw new InvalidOperationException($"Unsupported AI provider '{providerId}'."); + } + + return provider; + } + } + + internal interface IAiSourceBubblesTranslationProvider + { + string ProviderId { get; } + + Task TranslateAsync( + CollectionSettings collectionSettings, + string sourceText, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient + ); + } + + internal sealed class DeepLAiSourceBubblesTranslationProvider + : IAiSourceBubblesTranslationProvider + { + public string ProviderId => "deepl"; + + public async Task TranslateAsync( + CollectionSettings collectionSettings, + string sourceText, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient + ) + { + if (string.IsNullOrWhiteSpace(collectionSettings.AiSourceBubblesDeepLApiKey)) + { + throw new InvalidOperationException( + "Set a DeepL API key in Collection Settings > AI Source Bubbles." + ); + } + + var requestBody = new + { + text = new[] { sourceText }, + source_lang = NormalizeDeepLLanguageTag(sourceLanguageTag), + target_lang = NormalizeDeepLLanguageTag(targetLanguageTag), + }; + + using var request = new HttpRequestMessage( + HttpMethod.Post, + GetEndpoint(collectionSettings.AiSourceBubblesDeepLApiKey) + ); + request.Content = new StringContent( + JsonConvert.SerializeObject(requestBody), + Encoding.UTF8, + "application/json" + ); + request.Headers.Authorization = new AuthenticationHeaderValue( + "DeepL-Auth-Key", + collectionSettings.AiSourceBubblesDeepLApiKey.Trim() + ); + + using var response = await httpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + AiSourceBubblesProviderHelpers.EnsureSuccess(response, responseContent, "DeepL"); + + var responseJson = JObject.Parse(responseContent); + var translatedText = responseJson["translations"]?[0]?["text"]?.Value(); + if (string.IsNullOrWhiteSpace(translatedText)) + { + throw new InvalidOperationException("DeepL returned no translated text."); + } + + return translatedText; + } + + private static string GetEndpoint(string apiKey) + { + return apiKey.Trim().EndsWith(":fx", StringComparison.OrdinalIgnoreCase) + ? "https://api-free.deepl.com/v2/translate" + : "https://api.deepl.com/v2/translate"; + } + + private static string NormalizeDeepLLanguageTag(string languageTag) + { + return AiSourceBubblesService + .NormalizeBloomLanguageTag(languageTag) + .Replace('_', '-') + .ToUpperInvariant(); + } + } + + internal sealed class GoogleAiSourceBubblesTranslationProvider + : IAiSourceBubblesTranslationProvider + { + private const string kScope = "https://www.googleapis.com/auth/cloud-translation"; + private const string kTokenEndpoint = "https://oauth2.googleapis.com/token"; + private const string kTranslateEndpoint = + "https://translation.googleapis.com/language/translate/v2"; + + public string ProviderId => "google"; + + public async Task TranslateAsync( + CollectionSettings collectionSettings, + string sourceText, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient + ) + { + if ( + string.IsNullOrWhiteSpace( + collectionSettings.AiSourceBubblesGoogleServiceAccountEmail + ) + ) + { + throw new InvalidOperationException( + "Set a Google service account email in Collection Settings > AI Source Bubbles." + ); + } + if (string.IsNullOrWhiteSpace(collectionSettings.AiSourceBubblesGooglePrivateKey)) + { + throw new InvalidOperationException( + "Set a Google service account private key in Collection Settings > AI Source Bubbles." + ); + } + + var accessToken = await GetAccessTokenAsync(collectionSettings, httpClient); + var fields = new List> + { + new KeyValuePair("q", sourceText), + new KeyValuePair("target", targetLanguageTag), + new KeyValuePair("format", "text"), + }; + var normalizedSourceLanguage = AiSourceBubblesService.NormalizeBloomLanguageTag( + sourceLanguageTag + ); + if (!string.IsNullOrWhiteSpace(normalizedSourceLanguage)) + { + fields.Add(new KeyValuePair("source", normalizedSourceLanguage)); + } + + using var request = new HttpRequestMessage(HttpMethod.Post, kTranslateEndpoint) + { + Content = new FormUrlEncodedContent(fields), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await httpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + AiSourceBubblesProviderHelpers.EnsureSuccess( + response, + responseContent, + "Google Translate" + ); + + var responseJson = JObject.Parse(responseContent); + var translatedText = responseJson["data"] + ?["translations"]?[0]?["translatedText"]?.Value(); + if (string.IsNullOrWhiteSpace(translatedText)) + { + throw new InvalidOperationException( + "Google Translate returned no translated text." + ); + } + + return WebUtility.HtmlDecode(translatedText); + } + + private static async Task GetAccessTokenAsync( + CollectionSettings collectionSettings, + HttpClient httpClient + ) + { + var now = DateTimeOffset.UtcNow; + var jwtHeader = AiSourceBubblesProviderHelpers.Base64UrlEncode( + Encoding.UTF8.GetBytes( + JsonConvert.SerializeObject(new { alg = "RS256", typ = "JWT" }) + ) + ); + var jwtPayload = AiSourceBubblesProviderHelpers.Base64UrlEncode( + Encoding.UTF8.GetBytes( + JsonConvert.SerializeObject( + new + { + iss = collectionSettings.AiSourceBubblesGoogleServiceAccountEmail, + scope = kScope, + aud = kTokenEndpoint, + iat = now.ToUnixTimeSeconds(), + exp = now.AddMinutes(59).ToUnixTimeSeconds(), + } + ) + ) + ); + var signingInput = $"{jwtHeader}.{jwtPayload}"; + var signedJwt = + $"{signingInput}.{SignJwt(signingInput, collectionSettings.AiSourceBubblesGooglePrivateKey)}"; + + using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, kTokenEndpoint) + { + Content = new FormUrlEncodedContent( + new[] + { + new KeyValuePair( + "grant_type", + "urn:ietf:params:oauth:grant-type:jwt-bearer" + ), + new KeyValuePair("assertion", signedJwt), + } + ), + }; + + using var tokenResponse = await httpClient.SendAsync(tokenRequest); + var tokenContent = await tokenResponse.Content.ReadAsStringAsync(); + AiSourceBubblesProviderHelpers.EnsureSuccess( + tokenResponse, + tokenContent, + "Google OAuth" + ); + + var tokenJson = JObject.Parse(tokenContent); + var accessToken = tokenJson["access_token"]?.Value(); + if (string.IsNullOrWhiteSpace(accessToken)) + { + throw new InvalidOperationException("Google OAuth returned no access token."); + } + + return accessToken; + } + + private static string SignJwt(string signingInput, string privateKey) + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(NormalizePrivateKey(privateKey).ToCharArray()); + var signature = rsa.SignData( + Encoding.UTF8.GetBytes(signingInput), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1 + ); + return AiSourceBubblesProviderHelpers.Base64UrlEncode(signature); + } + + private static string NormalizePrivateKey(string privateKey) + { + return privateKey.Replace("\\r", "").Replace("\\n", "\n").Trim(); + } + } + + internal static class AiSourceBubblesProviderHelpers + { + internal static void EnsureSuccess( + HttpResponseMessage response, + string responseContent, + string providerName + ) + { + if (response.IsSuccessStatusCode) + return; + + var providerMessage = responseContent; + try + { + var json = JObject.Parse(responseContent); + providerMessage = + json["message"]?.Value() + ?? json["error"]?.Value() + ?? json["error"]?["message"]?.Value() + ?? responseContent; + } + catch + { + // Keep the original response text when it isn't JSON. + } + + throw new InvalidOperationException( + $"{providerName} request failed: {(int)response.StatusCode} {response.ReasonPhrase}. {providerMessage}".Trim() + ); + } + + internal static string Base64UrlEncode(byte[] bytes) + { + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + } +} diff --git a/src/BloomExe/Book/Book.cs b/src/BloomExe/Book/Book.cs index 045414fa8646..7120900dec14 100644 --- a/src/BloomExe/Book/Book.cs +++ b/src/BloomExe/Book/Book.cs @@ -6144,12 +6144,14 @@ public bool IsPlayground public void AITranslate() { - var groups = this.Storage.Dom.RawDom.SelectNodes( - "//div[contains(@class, 'bloom-translationGroup')]" - ); + var groups = this + .Storage.Dom.RawDom.SafeSelectNodes( + "//div[contains(@class, 'bloom-translationGroup')]" + ) + .Cast(); var sourceLang = "en"; var targetLang = "es"; - foreach (XmlNode group in groups) + foreach (var group in groups) { var node = group.SelectSingleNode($"div[@lang = '{targetLang}-x-ai']"); if (node != null) @@ -6201,7 +6203,6 @@ Action setValue // var response = client.SendAsync(request).Result; // // wait for the response - // response.EnsureSuccessStatusCode(); // var responseContent = await airequest.Content.ReadAsStringAsync(); diff --git a/src/BloomExe/Book/RuntimeInformationInjector.cs b/src/BloomExe/Book/RuntimeInformationInjector.cs index 40eded781fbe..3984117c3db2 100644 --- a/src/BloomExe/Book/RuntimeInformationInjector.cs +++ b/src/BloomExe/Book/RuntimeInformationInjector.cs @@ -2,9 +2,11 @@ using System.Collections.Generic; using System.Linq; using System.Xml; +using Bloom.AiSourceBubbles; using Bloom.Collection; using Bloom.Properties; using Bloom.SafeXml; +using Bloom.SubscriptionAndFeatures; using L10NSharp; using Newtonsoft.Json; using SIL.IO; @@ -459,7 +461,7 @@ IFileLocator fileLocator var element = pageDom.RawDom.CreateElement("script"); element.SetAttribute("type", "text/javascript"); element.SetAttribute("id", "ui-settings"); - var d = new Dictionary(); + var d = new Dictionary(); //d.Add("urlOfUIFiles", "file:///" + fileLocator.LocateDirectory("ui", "ui files directory")); if (!String.IsNullOrEmpty(Settings.Default.LastSourceLanguageViewed)) @@ -490,6 +492,30 @@ IFileLocator fileLocator .ToLocalhost() ); + var aiSourceBubblesFeatureStatus = FeatureStatus.GetFeatureStatus( + bookData.CollectionSettings.Subscription, + FeatureName.AiSourceBubbles + ); + var aiSourceBubblesProviderId = AiSourceBubblesService.NormalizeProviderId( + bookData.CollectionSettings.AiSourceBubblesProviderId + ); + var aiSourceBubblesTargetLanguageTag = AiSourceBubblesService.NormalizeBloomLanguageTag( + bookData.CollectionSettings.AiSourceBubblesTargetLanguageTag + ); + d.Add( + "allowAiSourceBubbles", + aiSourceBubblesFeatureStatus.Visible && aiSourceBubblesFeatureStatus.Enabled + ); + d.Add("aiSourceBubblesProvider", aiSourceBubblesProviderId); + d.Add("aiSourceBubblesTargetLanguageTag", aiSourceBubblesTargetLanguageTag); + d.Add( + "aiSourceBubblesLanguageTag", + AiSourceBubblesService.GetAiLanguageTag( + aiSourceBubblesTargetLanguageTag, + aiSourceBubblesProviderId + ) + ); + element.InnerText = String.Format( "function GetSettings() {{ return {0};}}", JsonConvert.SerializeObject(d) diff --git a/src/BloomExe/Collection/CollectionSettings.cs b/src/BloomExe/Collection/CollectionSettings.cs index 1a55db3ea6e7..fa2e09977ac3 100644 --- a/src/BloomExe/Collection/CollectionSettings.cs +++ b/src/BloomExe/Collection/CollectionSettings.cs @@ -72,6 +72,12 @@ public class CollectionSettings // if this is null, relevant code uses the default, so we don't have to initialize it here public string BadgeQrCodeLabel; + public string AiSourceBubblesProviderId = "deepl"; + public string AiSourceBubblesTargetLanguageTag = ""; + public string AiSourceBubblesDeepLApiKey = ""; + public string AiSourceBubblesGoogleServiceAccountEmail = ""; + public string AiSourceBubblesGooglePrivateKey = ""; + public static readonly Dictionary CssNumberStylesToCultureOrDigits = new Dictionary() { @@ -400,6 +406,20 @@ public void Save() xml.Add(BulkPublishBloomPubSettings.ToXElement()); xml.Add(new XElement("ShowBlorgLanguageQrCode", ShowBlorgLanguageQrCode)); xml.Add(new XElement("BadgeQrCodeLabel", BadgeQrCodeLabel)); + xml.Add(new XElement("AiSourceBubblesProviderId", AiSourceBubblesProviderId)); + xml.Add( + new XElement("AiSourceBubblesTargetLanguageTag", AiSourceBubblesTargetLanguageTag) + ); + xml.Add(new XElement("AiSourceBubblesDeepLApiKey", AiSourceBubblesDeepLApiKey)); + xml.Add( + new XElement( + "AiSourceBubblesGoogleServiceAccountEmail", + AiSourceBubblesGoogleServiceAccountEmail + ) + ); + xml.Add( + new XElement("AiSourceBubblesGooglePrivateKey", AiSourceBubblesGooglePrivateKey) + ); RobustIO.SaveXElement(xml, SettingsFilePath); // Color palette settings are stored in a separate Json file @@ -674,6 +694,23 @@ public void Load() ShowBlorgLanguageQrCode = ReadBoolean(xml, "ShowBlorgLanguageQrCode", true); BadgeQrCodeLabel = ReadString(xml, "BadgeQrCodeLabel", ""); + AiSourceBubblesProviderId = ReadString(xml, "AiSourceBubblesProviderId", "deepl"); + AiSourceBubblesTargetLanguageTag = ReadString( + xml, + "AiSourceBubblesTargetLanguageTag", + "" + ); + AiSourceBubblesDeepLApiKey = ReadString(xml, "AiSourceBubblesDeepLApiKey", ""); + AiSourceBubblesGoogleServiceAccountEmail = ReadString( + xml, + "AiSourceBubblesGoogleServiceAccountEmail", + "" + ); + AiSourceBubblesGooglePrivateKey = ReadString( + xml, + "AiSourceBubblesGooglePrivateKey", + "" + ); LoadDictionary(xml, "Palette", ColorPalettes); } diff --git a/src/BloomExe/Collection/CollectionSettingsDialog.cs b/src/BloomExe/Collection/CollectionSettingsDialog.cs index 6634a1586b89..9bfaeefdeca8 100644 --- a/src/BloomExe/Collection/CollectionSettingsDialog.cs +++ b/src/BloomExe/Collection/CollectionSettingsDialog.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Windows.Forms; +using Bloom.AiSourceBubbles; using Bloom.Book; using Bloom.MiscUI; using Bloom.Properties; @@ -51,6 +52,12 @@ public string PendingDefaultBookshelf internal bool PendingAllowTeamCollection; internal bool PendingAllowAppBuilder; + internal bool PendingAllowAiSourceBubbles; + internal string PendingAiSourceBubblesProviderId; + internal string PendingAiSourceBubblesTargetLanguageTag; + internal string PendingAiSourceBubblesDeepLApiKey; + internal string PendingAiSourceBubblesGoogleServiceAccountEmail; + internal string PendingAiSourceBubblesGooglePrivateKey; internal bool AllowTeamCollectionOptionEnabled = false; // "Internal" so CollectionSettingsApi can update these. @@ -121,6 +128,19 @@ XMatterPackFinder xmatterPackFinder PendingAllowAppBuilder = ExperimentalFeatures.IsFeatureEnabled( ExperimentalFeatures.kAppBuilder ); + PendingAllowAiSourceBubbles = ExperimentalFeatures.IsFeatureEnabled( + ExperimentalFeatures.kAiSourceBubbles + ); + PendingAiSourceBubblesProviderId = AiSourceBubblesService.NormalizeProviderId( + _collectionSettings.AiSourceBubblesProviderId + ); + PendingAiSourceBubblesTargetLanguageTag = + _collectionSettings.AiSourceBubblesTargetLanguageTag; + PendingAiSourceBubblesDeepLApiKey = _collectionSettings.AiSourceBubblesDeepLApiKey; + PendingAiSourceBubblesGoogleServiceAccountEmail = + _collectionSettings.AiSourceBubblesGoogleServiceAccountEmail; + PendingAiSourceBubblesGooglePrivateKey = + _collectionSettings.AiSourceBubblesGooglePrivateKey; if ( !ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kTeamCollections) @@ -410,6 +430,16 @@ private void _okButton_Click(object sender, EventArgs e) UpdateExperimentalBookSources(); UpdateTeamCollectionAllowed(); UpdateAppBuilderAllowed(); + UpdateAiSourceBubblesAllowed(); + _collectionSettings.AiSourceBubblesProviderId = + AiSourceBubblesService.NormalizeProviderId(PendingAiSourceBubblesProviderId); + _collectionSettings.AiSourceBubblesTargetLanguageTag = + PendingAiSourceBubblesTargetLanguageTag; + _collectionSettings.AiSourceBubblesDeepLApiKey = PendingAiSourceBubblesDeepLApiKey; + _collectionSettings.AiSourceBubblesGoogleServiceAccountEmail = + PendingAiSourceBubblesGoogleServiceAccountEmail; + _collectionSettings.AiSourceBubblesGooglePrivateKey = + PendingAiSourceBubblesGooglePrivateKey; _collectionSettings.Country = _countryText.Text.Trim(); _collectionSettings.Province = _provinceText.Text.Trim(); @@ -829,5 +859,14 @@ private void UpdateAppBuilderAllowed() // NB: This change does not require a restart. ExperimentalFeatures.SetValue(ExperimentalFeatures.kAppBuilder, PendingAllowAppBuilder); } + + private void UpdateAiSourceBubblesAllowed() + { + // NB: This change does not require a restart. + ExperimentalFeatures.SetValue( + ExperimentalFeatures.kAiSourceBubbles, + PendingAllowAiSourceBubbles + ); + } } } diff --git a/src/BloomExe/ExperimentalFeatures.cs b/src/BloomExe/ExperimentalFeatures.cs index fc8f2c61df57..0e89dfa9eb5d 100644 --- a/src/BloomExe/ExperimentalFeatures.cs +++ b/src/BloomExe/ExperimentalFeatures.cs @@ -11,6 +11,7 @@ public static class ExperimentalFeatures public const string kExperimentalSourceBooks = "experimental-source-books"; public const string kTeamCollections = "team-collections"; public const string kAppBuilder = "app-builder"; + public const string kAiSourceBubbles = "ai-source-bubbles"; public static string TokensOfEnabledFeatures => Settings.Default.EnabledExperimentalFeatures; diff --git a/src/BloomExe/ProjectContext.cs b/src/BloomExe/ProjectContext.cs index 7a681fcbc377..9708ea97a60e 100644 --- a/src/BloomExe/ProjectContext.cs +++ b/src/BloomExe/ProjectContext.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Windows.Forms; using Autofac; +using Bloom.AiSourceBubbles; using Bloom.Api; using Bloom.Book; using Bloom.Collection; @@ -161,6 +162,8 @@ IContainer parentContainer typeof(RabPublishApi), typeof(AccessibilityCheckApi), typeof(CollectionSettingsApi), + typeof(AiSourceBubblesApi), + typeof(AiSourceBubblesService), typeof(SubscriptionSettingsEditorApi), typeof(FeatureStatusApi), typeof(CollectionTabView), @@ -425,6 +428,7 @@ IContainer parentContainer _scope.Resolve().RegisterWithApiHandler(server.ApiHandler); _scope.Resolve().RegisterWithApiHandler(server.ApiHandler); _scope.Resolve().RegisterWithApiHandler(server.ApiHandler); + _scope.Resolve().RegisterWithApiHandler(server.ApiHandler); _scope .Resolve() .RegisterWithApiHandler(server.ApiHandler); diff --git a/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs b/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs index d12cc757a6a1..738df5853212 100644 --- a/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs +++ b/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs @@ -29,6 +29,7 @@ public enum FeatureName BulkBloomPub, Bookshelf, AppBuilder, + AiSourceBubbles, } public static class FeatureRegistry @@ -194,6 +195,12 @@ public static class FeatureRegistry SubscriptionTier = SubscriptionTier.Pro, ExperimentalFeatureToken = Bloom.ExperimentalFeatures.kAppBuilder, }, + new FeatureInfo + { + Feature = FeatureName.AiSourceBubbles, + SubscriptionTier = SubscriptionTier.Pro, + ExperimentalFeatureToken = Bloom.ExperimentalFeatures.kAiSourceBubbles, + }, // ---------------------------------------- // Enterprise Tier Features // ---------------------------------------- diff --git a/src/BloomExe/web/controllers/AiSourceBubblesApi.cs b/src/BloomExe/web/controllers/AiSourceBubblesApi.cs new file mode 100644 index 000000000000..7f6da1e165cc --- /dev/null +++ b/src/BloomExe/web/controllers/AiSourceBubblesApi.cs @@ -0,0 +1,63 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Bloom.AiSourceBubbles; +using Bloom.Api; +using Newtonsoft.Json.Linq; + +namespace Bloom.web.controllers +{ + public class AiSourceBubblesApi + { + private const string kApiUrlPart = "aiSourceBubbles/"; + private readonly AiSourceBubblesService _aiSourceBubblesService; + + public AiSourceBubblesApi(AiSourceBubblesService aiSourceBubblesService) + { + _aiSourceBubblesService = aiSourceBubblesService; + } + + /// + /// Registers API endpoints for AI Source Bubbles. + /// + public void RegisterWithApiHandler(BloomApiHandler apiHandler) + { + apiHandler.RegisterAsyncEndpointHandler( + kApiUrlPart + "translate", + HandleTranslateAsync, + false, + false + ); + } + + private async Task HandleTranslateAsync(ApiRequest request) + { + if (request.HttpMethod != HttpMethods.Post) + { + request.Failed(HttpStatusCode.MethodNotAllowed, "Only POST is supported."); + return; + } + + try + { + var requestJson = JObject.Parse(request.RequiredPostJson()); + var response = await _aiSourceBubblesService.TranslateAsync( + new AiSourceBubblesTranslateRequest + { + SourceText = requestJson["sourceText"]?.Value(), + SourceLanguageTag = requestJson["sourceLanguageTag"]?.Value(), + } + ); + request.ReplyWithJson(response); + } + catch (ArgumentException e) + { + request.Failed(HttpStatusCode.BadRequest, e.Message); + } + catch (InvalidOperationException e) + { + request.Failed(HttpStatusCode.BadRequest, e.Message); + } + } + } +} diff --git a/src/BloomExe/web/controllers/CollectionSettingsApi.cs b/src/BloomExe/web/controllers/CollectionSettingsApi.cs index 0cc43ad69eb5..df1e3820b145 100644 --- a/src/BloomExe/web/controllers/CollectionSettingsApi.cs +++ b/src/BloomExe/web/controllers/CollectionSettingsApi.cs @@ -3,6 +3,7 @@ using System.Dynamic; using System.Globalization; using System.Text; +using Bloom.AiSourceBubbles; using Bloom.Api; using Bloom.Book; using Bloom.Collection; @@ -318,6 +319,22 @@ private object GetAdvancedSettingsData() ), allowAppBuilder = dialog?.PendingAllowAppBuilder ?? ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kAppBuilder), + allowAiSourceBubbles = dialog?.PendingAllowAiSourceBubbles + ?? ExperimentalFeatures.IsFeatureEnabled( + ExperimentalFeatures.kAiSourceBubbles + ), + aiSourceBubblesProvider = AiSourceBubblesService.NormalizeProviderId( + dialog?.PendingAiSourceBubblesProviderId + ?? _collectionSettings.AiSourceBubblesProviderId + ), + aiSourceBubblesTargetLanguageTag = dialog?.PendingAiSourceBubblesTargetLanguageTag + ?? _collectionSettings.AiSourceBubblesTargetLanguageTag, + aiSourceBubblesDeepLApiKey = dialog?.PendingAiSourceBubblesDeepLApiKey + ?? _collectionSettings.AiSourceBubblesDeepLApiKey, + aiSourceBubblesGoogleServiceAccountEmail = dialog?.PendingAiSourceBubblesGoogleServiceAccountEmail + ?? _collectionSettings.AiSourceBubblesGoogleServiceAccountEmail, + aiSourceBubblesGooglePrivateKey = dialog?.PendingAiSourceBubblesGooglePrivateKey + ?? _collectionSettings.AiSourceBubblesGooglePrivateKey, showQrCode = dialog?.PendingShowQrCode ?? _collectionSettings.ShowBlorgLanguageQrCode, qrcodeCaption = dialog?.PendingBadgeQrCodeCaption @@ -327,9 +344,52 @@ private object GetAdvancedSettingsData() showExperimentalBookSourcesOption = dialog?.ShowExperimentalBookSourcesOption ?? false, allowTeamCollectionEnabled = dialog?.AllowTeamCollectionOptionEnabled ?? true, + aiSourceBubblesKnownTargetLanguages = GetAiSourceBubblesKnownTargetLanguages( + dialog + ), }; } + private List GetAiSourceBubblesKnownTargetLanguages(CollectionSettingsDialog dialog) + { + var pendingOrCurrentWritingSystems = new[] + { + dialog?.PendingLanguage1 ?? _collectionSettings.Language1, + dialog?.PendingLanguage2 ?? _collectionSettings.Language2, + dialog?.PendingLanguage3 ?? _collectionSettings.Language3, + }; + + var knownLanguages = new List(); + var seenLanguageTags = new HashSet(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < pendingOrCurrentWritingSystems.Length; i++) + { + var writingSystem = pendingOrCurrentWritingSystems[i]; + if (writingSystem == null || string.IsNullOrWhiteSpace(writingSystem.Tag)) + { + continue; + } + + if (!seenLanguageTags.Add(writingSystem.Tag)) + { + continue; + } + + var displayName = string.IsNullOrWhiteSpace(writingSystem.Name) + ? writingSystem.Tag + : writingSystem.Name; + knownLanguages.Add( + new + { + value = writingSystem.Tag, + label = $"L{i + 1}: {displayName} ({writingSystem.Tag})", + } + ); + } + + return knownLanguages; + } + private void StoreAdvancedSettingsData(ApiRequest request, CollectionSettingsDialog dialog) { var data = JObject.Parse(request.RequiredPostJson()); @@ -360,6 +420,52 @@ private void StoreAdvancedSettingsData(ApiRequest request, CollectionSettingsDia dialog.PendingAllowAppBuilder = allowAppBuilder; } + var allowAiSourceBubblesToken = data["allowAiSourceBubbles"]; + if (allowAiSourceBubblesToken != null) + { + var allowAiSourceBubbles = allowAiSourceBubblesToken.Value(); + dialog.PendingAllowAiSourceBubbles = allowAiSourceBubbles; + } + + var aiSourceBubblesProviderToken = data["aiSourceBubblesProvider"]; + if (aiSourceBubblesProviderToken != null) + { + dialog.PendingAiSourceBubblesProviderId = + AiSourceBubblesService.NormalizeProviderId( + aiSourceBubblesProviderToken.Value() + ); + } + + var aiSourceBubblesTargetLanguageTagToken = data["aiSourceBubblesTargetLanguageTag"]; + if (aiSourceBubblesTargetLanguageTagToken != null) + { + dialog.PendingAiSourceBubblesTargetLanguageTag = + aiSourceBubblesTargetLanguageTagToken.Value(); + } + + var aiSourceBubblesDeepLApiKeyToken = data["aiSourceBubblesDeepLApiKey"]; + if (aiSourceBubblesDeepLApiKeyToken != null) + { + dialog.PendingAiSourceBubblesDeepLApiKey = + aiSourceBubblesDeepLApiKeyToken.Value(); + } + + var aiSourceBubblesGoogleServiceAccountEmailToken = data[ + "aiSourceBubblesGoogleServiceAccountEmail" + ]; + if (aiSourceBubblesGoogleServiceAccountEmailToken != null) + { + dialog.PendingAiSourceBubblesGoogleServiceAccountEmail = + aiSourceBubblesGoogleServiceAccountEmailToken.Value(); + } + + var aiSourceBubblesGooglePrivateKeyToken = data["aiSourceBubblesGooglePrivateKey"]; + if (aiSourceBubblesGooglePrivateKeyToken != null) + { + dialog.PendingAiSourceBubblesGooglePrivateKey = + aiSourceBubblesGooglePrivateKeyToken.Value(); + } + var showQrCodeToken = data["showQrCode"]; if (showQrCodeToken != null) { diff --git a/src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs b/src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs new file mode 100644 index 000000000000..aea969466c67 --- /dev/null +++ b/src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs @@ -0,0 +1,193 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Bloom; +using Bloom.AiSourceBubbles; +using Bloom.Collection; +using Bloom.SubscriptionAndFeatures; +using NUnit.Framework; + +namespace BloomTests.AiSourceBubbles +{ + [TestFixture] + public class AiSourceBubblesServiceTests + { + private bool _previousAiSourceBubblesEnabled; + + [SetUp] + public void Setup() + { + _previousAiSourceBubblesEnabled = ExperimentalFeatures.IsFeatureEnabled( + ExperimentalFeatures.kAiSourceBubbles + ); + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, true); + } + + [TearDown] + public void TearDown() + { + ExperimentalFeatures.SetValue( + ExperimentalFeatures.kAiSourceBubbles, + _previousAiSourceBubblesEnabled + ); + } + + [Test] + public void NormalizeProviderId_GoogleTranslateAlias_ReturnsGoogle() + { + Assert.That( + AiSourceBubblesService.NormalizeProviderId("googleTranslate"), + Is.EqualTo("google") + ); + } + + [Test] + public void GetAiLanguageTag_AppendsNormalizedProviderSuffix() + { + Assert.That( + AiSourceBubblesService.GetAiLanguageTag("fr", "googleTranslate"), + Is.EqualTo("fr-x-ai-google") + ); + } + + [Test] + public void TranslateAsync_WithoutTargetLanguageTag_ThrowsHelpfulError() + { + var collectionSettings = MakeCollectionSettings("deepl"); + collectionSettings.AiSourceBubblesTargetLanguageTag = ""; + var service = new AiSourceBubblesService(collectionSettings); + + var exception = Assert.ThrowsAsync(async () => + await service.TranslateAsync( + new AiSourceBubblesTranslateRequest + { + SourceText = "Hello world.", + SourceLanguageTag = "en", + } + ) + ); + + Assert.That(exception.Message, Does.Contain("target language tag")); + } + + private static CollectionSettings MakeCollectionSettings(string providerId) + { + var collectionSettings = new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), + AiSourceBubblesProviderId = providerId, + AiSourceBubblesTargetLanguageTag = "fr", + }; + return collectionSettings; + } + } + + public abstract class AiSourceBubblesLiveTranslationTestsBase + { + private bool _previousAiSourceBubblesEnabled; + + protected abstract string ProviderId { get; } + protected abstract string[] RequiredEnvironmentVariables { get; } + protected abstract void PopulateCredentials(CollectionSettings collectionSettings); + + [SetUp] + public void Setup() + { + _previousAiSourceBubblesEnabled = ExperimentalFeatures.IsFeatureEnabled( + ExperimentalFeatures.kAiSourceBubbles + ); + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, true); + } + + [TearDown] + public void TearDown() + { + ExperimentalFeatures.SetValue( + ExperimentalFeatures.kAiSourceBubbles, + _previousAiSourceBubblesEnabled + ); + } + + [Test] + public async Task TranslateAsync_ConfiguredProvider_ReturnsTranslatedText() + { + var missingVariables = RequiredEnvironmentVariables + .Where(variableName => + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(variableName)) + ) + .ToArray(); + if (missingVariables.Any()) + { + Assert.Ignore( + $"Manual AI Source Bubbles provider test. Set {string.Join(", ", missingVariables)} to run it." + ); + } + + var collectionSettings = new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), + AiSourceBubblesProviderId = ProviderId, + AiSourceBubblesTargetLanguageTag = "fr", + }; + PopulateCredentials(collectionSettings); + var service = new AiSourceBubblesService(collectionSettings); + + var result = await service.TranslateAsync( + new AiSourceBubblesTranslateRequest + { + SourceText = "Hello world.", + SourceLanguageTag = "en", + } + ); + + Assert.That(result.ProviderId, Is.EqualTo(ProviderId)); + Assert.That(result.TargetLanguageTag, Is.EqualTo("fr")); + Assert.That(result.AiLanguageTag, Is.EqualTo($"fr-x-ai-{ProviderId}")); + Assert.That(result.Text, Is.Not.Null.And.Not.Empty); + Assert.That(result.Text, Is.Not.EqualTo("Hello world.")); + } + } + + [TestFixture] + [Category("SkipOnTeamCity")] + [NonParallelizable] + public class DeepLAiSourceBubblesLiveTranslationTests : AiSourceBubblesLiveTranslationTestsBase + { + protected override string ProviderId => "deepl"; + + protected override string[] RequiredEnvironmentVariables => new[] { "BLOOM_DEEPL_KEY" }; + + protected override void PopulateCredentials(CollectionSettings collectionSettings) + { + collectionSettings.AiSourceBubblesDeepLApiKey = Environment.GetEnvironmentVariable( + "BLOOM_DEEPL_KEY" + ); + } + } + + [TestFixture] + [Category("SkipOnTeamCity")] + [NonParallelizable] + public class GoogleAiSourceBubblesLiveTranslationTests : AiSourceBubblesLiveTranslationTestsBase + { + protected override string ProviderId => "google"; + + protected override string[] RequiredEnvironmentVariables => + new[] + { + "BLOOM_GOOGLE_TRANSLATION_SERVICE_ACCOUNT_EMAIL", + "BLOOM_GOOGLE_TRANSLATION_SERVICE_PRIVATE_KEY", + }; + + protected override void PopulateCredentials(CollectionSettings collectionSettings) + { + collectionSettings.AiSourceBubblesGoogleServiceAccountEmail = + Environment.GetEnvironmentVariable( + "BLOOM_GOOGLE_TRANSLATION_SERVICE_ACCOUNT_EMAIL" + ); + collectionSettings.AiSourceBubblesGooglePrivateKey = Environment.GetEnvironmentVariable( + "BLOOM_GOOGLE_TRANSLATION_SERVICE_PRIVATE_KEY" + ); + } + } +} diff --git a/src/BloomTests/ExperimentalFeaturesTests.cs b/src/BloomTests/ExperimentalFeaturesTests.cs index 2303e32ad47c..42610d4eb718 100644 --- a/src/BloomTests/ExperimentalFeaturesTests.cs +++ b/src/BloomTests/ExperimentalFeaturesTests.cs @@ -32,18 +32,25 @@ public void SetValueWorksProperly() ExperimentalFeatures.SetValue(ExperimentalFeatures.kExperimentalSourceBooks, true); ExperimentalFeatures.SetValue(ExperimentalFeatures.kTeamCollections, true); + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, true); ExperimentalFeatures.SetValue(ExperimentalFeatures.kExperimentalSourceBooks, true); ExperimentalFeatures.SetValue(ExperimentalFeatures.kTeamCollections, true); + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, true); Assert.IsTrue( ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kExperimentalSourceBooks) ); Assert.IsTrue( ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kTeamCollections) ); + Assert.IsTrue( + ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kAiSourceBubbles) + ); Assert.AreEqual( ExperimentalFeatures.kExperimentalSourceBooks + "," - + ExperimentalFeatures.kTeamCollections, + + ExperimentalFeatures.kTeamCollections + + "," + + ExperimentalFeatures.kAiSourceBubbles, ExperimentalFeatures.TokensOfEnabledFeatures ); @@ -54,8 +61,11 @@ public void SetValueWorksProperly() Assert.IsTrue( ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kTeamCollections) ); + Assert.IsTrue( + ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kAiSourceBubbles) + ); Assert.AreEqual( - ExperimentalFeatures.kTeamCollections, + ExperimentalFeatures.kTeamCollections + "," + ExperimentalFeatures.kAiSourceBubbles, ExperimentalFeatures.TokensOfEnabledFeatures ); @@ -66,6 +76,18 @@ public void SetValueWorksProperly() Assert.IsFalse( ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kTeamCollections) ); + Assert.IsTrue( + ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kAiSourceBubbles) + ); + Assert.AreEqual( + ExperimentalFeatures.kAiSourceBubbles, + ExperimentalFeatures.TokensOfEnabledFeatures + ); + + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, false); + Assert.IsFalse( + ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kAiSourceBubbles) + ); Assert.AreEqual("", ExperimentalFeatures.TokensOfEnabledFeatures); } } diff --git a/src/BloomTests/Subscription/FeatureStatusTests.cs b/src/BloomTests/Subscription/FeatureStatusTests.cs index 0093def14bd3..50f6d9d5d4aa 100644 --- a/src/BloomTests/Subscription/FeatureStatusTests.cs +++ b/src/BloomTests/Subscription/FeatureStatusTests.cs @@ -134,6 +134,52 @@ public void GetFeatureStatus_ExperimentalFeatureStillRequiresSubscription() Assert.That(status.SubscriptionTier, Is.EqualTo(SubscriptionTier.Pro)); } + [Test] + public void GetFeatureStatus_AiSourceBubblesHiddenUnlessEnabled() + { + var subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro); + + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, false); + var hiddenStatus = FeatureStatus.GetFeatureStatus( + subscription, + FeatureName.AiSourceBubbles + ); + + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, true); + var visibleStatus = FeatureStatus.GetFeatureStatus( + subscription, + FeatureName.AiSourceBubbles + ); + + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, false); + + Assert.That(hiddenStatus.Visible, Is.False); + Assert.That(hiddenStatus.Enabled, Is.True); + Assert.That(visibleStatus.Visible, Is.True); + Assert.That(visibleStatus.Enabled, Is.True); + } + + [TestCase(SubscriptionTier.Basic, false)] + [TestCase(SubscriptionTier.Pro, true)] + [TestCase(SubscriptionTier.LocalCommunity, true)] + [TestCase(SubscriptionTier.Enterprise, true)] + public void GetFeatureStatus_AiSourceBubblesRequiresProOrHigher( + SubscriptionTier tier, + bool expectedEnabled + ) + { + var subscription = Subscription.CreateTempSubscriptionForTier(tier); + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, true); + + var status = FeatureStatus.GetFeatureStatus(subscription, FeatureName.AiSourceBubbles); + + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, false); + + Assert.That(status.Visible, Is.True); + Assert.That(status.Enabled, Is.EqualTo(expectedEnabled)); + Assert.That(status.SubscriptionTier, Is.EqualTo(SubscriptionTier.Pro)); + } + [Test] public void ForSerialization_ReturnsValidObject() { From 0ddf29eb04dd1cd74470d9f1dcf90756bafabf83 Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 28 Apr 2026 15:36:02 -0600 Subject: [PATCH 04/10] wip --- DistFiles/localization/en/Bloom.xlf | 4 + .../sourceBubbles/BloomSourceBubbles.tsx | 54 +- .../sourceBubbles/SourceBubblesSpec.ts | 51 +- .../collection/AdvancedSettingsPanel.test.tsx | 415 ++++++++++++++ .../collection/AdvancedSettingsPanel.tsx | 259 ++------- .../AiSourceBubblesSettingsGroup.tsx | 518 ++++++++++++++++++ .../AiSourceBubbles/AiSourceBubblesService.cs | 474 ++++++++++++++-- .../Book/RuntimeInformationInjector.cs | 13 +- src/BloomExe/Collection/CollectionSettings.cs | 36 ++ .../Collection/CollectionSettingsDialog.cs | 15 + .../web/controllers/CollectionSettingsApi.cs | 307 +++++++++-- .../AiSourceBubblesServiceTests.cs | 203 +++++++ .../Book/RuntimeInformationInjectorTests.cs | 69 +++ 13 files changed, 2091 insertions(+), 327 deletions(-) create mode 100644 src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx create mode 100644 src/BloomBrowserUI/collection/AiSourceBubblesSettingsGroup.tsx diff --git a/DistFiles/localization/en/Bloom.xlf b/DistFiles/localization/en/Bloom.xlf index 230b713e225e..5faa5beb247d 100644 --- a/DistFiles/localization/en/Bloom.xlf +++ b/DistFiles/localization/en/Bloom.xlf @@ -407,6 +407,10 @@ DeepL API key ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLApiKeyLabel + + Alpha2 API key + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2ApiKeyLabel + Google service account email ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GoogleServiceAccountEmailLabel diff --git a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx index de66ae396e0a..953f442bb51c 100644 --- a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx +++ b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx @@ -207,11 +207,40 @@ export default class BloomSourceBubbles { }); } - private static maybeRememberSourceBubbleLanguage(langTag: string): void { - if (BloomSourceBubbles.isAiLanguageTag(langTag)) { + private static refreshVisibleAiSourceBubble(group: HTMLElement): void { + const qtipId = group.getAttribute("aria-describedby"); + if (!qtipId) { + return; + } + + const tooltip = group.ownerDocument.querySelector( + `#${qtipId}`, + ); + if (!tooltip || tooltip.getAttribute("aria-hidden") === "true") { + return; + } + + const selectedTab = tooltip.querySelector( + "a.sourceTextTab.active", + ); + const selectedLangTag = selectedTab?.getAttribute("href")?.substring(1); + const refreshedBubble = BloomSourceBubbles.MakeSourceTextDivForGroup( + group, + selectedLangTag, + ); + if (refreshedBubble.length === 0) { return; } + BloomSourceBubbles.removeSourceBubbles(group); + BloomSourceBubbles.MakeSourceBubblesIntoQtips( + group, + refreshedBubble, + selectedLangTag, + ); + } + + private static maybeRememberSourceBubbleLanguage(langTag: string): void { postString("editView/sourceTextTab", langTag); } @@ -403,6 +432,7 @@ export default class BloomSourceBubbles { group, aiDiv, ); + BloomSourceBubbles.refreshVisibleAiSourceBubble(group); } } }, @@ -560,9 +590,9 @@ export default class BloomSourceBubbles { ): JQuery { if (group.classList.contains("bloom-no-source-bubble")) return $(); const liveGroup = $(group); - const sourceDiv = BloomSourceBubbles.getPreferredSourceDiv(liveGroup); const aiSourceBubbleLangTag = BloomSourceBubbles.getAiSourceBubbleLangTag(); + const sourceDiv = BloomSourceBubbles.getPreferredSourceDiv(liveGroup); const sourceLanguageTag = sourceDiv.attr("lang"); if ( aiSourceBubbleLangTag && @@ -920,7 +950,23 @@ export default class BloomSourceBubbles { } private static styledSelectChangeHandler(event) { - const newLangTag = event.target.href.split("#")[1]; + const anchor = + event.target instanceof Element + ? event.target.closest("a.sourceTextTab") + : undefined; + const currentTarget = + event.currentTarget instanceof Element + ? event.currentTarget + : undefined; + const href = + anchor?.getAttribute("href") ?? + currentTarget + ?.querySelector("a.sourceTextTab") + ?.getAttribute("href"); + if (!href) { + return; + } + const newLangTag = href.split("#")[1]; // Figure out which qtip we're in and go find the associated bloom-translationGroup const qtip = $(event.target).closest(".qtip").attr("id"); diff --git a/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts b/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts index ddcd0fef76b4..172753052f66 100644 --- a/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts +++ b/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts @@ -484,7 +484,7 @@ describe("SourceBubbles", () => { }); }); - it("AI source bubble tabs are not remembered as the default source language", () => { + it("AI source bubble tabs are remembered as the default source language", () => { const aiLanguageTag = "id-x-ai-deepl"; const sourceText = "Tok Pisin text"; const fingerprint = ( @@ -516,12 +516,59 @@ describe("SourceBubbles", () => { expect(aiTab.length).toBe(1); aiTab.get(0)?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(mockedPostString).not.toHaveBeenCalledWith( + expect(mockedPostString).toHaveBeenCalledWith( "editView/sourceTextTab", aiLanguageTag, ); }); + it("styled dropdown handles clicks on the dropdown list item", () => { + const qtipId = "qtip-0"; + const groupHtml = $( + [ + `
    `, + "
    Tok Pisin text
    ", + "
    French text
    ", + "
    Spanish text
    ", + "
    ", + ].join("\n"), + ); + const qtip = $(`
    `); + const divForBubble = BloomSourceBubbles.CreateDropdownIfNecessary( + $( + [ + "
    ", + " ", + "
    Spanish text
    ", + "
    French text
    ", + "
    Tok Pisin text
    ", + "
    ", + ].join("\n"), + ), + ); + qtip.append(divForBubble); + $("body").append(groupHtml); + $("body").append(qtip); + const dropdownItem = divForBubble.find(".dropdown-list li[lang='es']"); + + expect(dropdownItem.length).toBe(1); + + dropdownItem + .get(0) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(mockedPostString).toHaveBeenCalledWith( + "editView/sourceTextTab", + "es", + ); + }); + it("translateSourceBubbleAsync maps PascalCase API response properties", async () => { mockedPostJsonAsync.mockResolvedValue({ data: { diff --git a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx new file mode 100644 index 000000000000..ba495c9512ea --- /dev/null +++ b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx @@ -0,0 +1,415 @@ +import * as React from "react"; +import ReactDOM from "react-dom"; +import { act } from "react-dom/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockPostJson, + mockPostJsonAsync, + configrPaneState, + initialAdvancedSettingsData, +} = vi.hoisted(() => ({ + mockPostJson: vi.fn(), + mockPostJsonAsync: vi.fn(), + configrPaneState: { + lastInitialValues: undefined as Record | undefined, + }, + initialAdvancedSettingsData: { + values: { + autoUpdate: true, + showExperimentalBookSources: false, + allowTeamCollection: false, + allowAppBuilder: false, + allowAiSourceBubbles: false, + aiSourceBubblesProvider: "deepl", + aiSourceBubblesTargetLanguageTag: "en", + aiSourceBubblesDeepLApiKey: "", + aiSourceBubblesGoogleServiceAccountEmail: "", + aiSourceBubblesGooglePrivateKey: "", + showQrCode: true, + qrcodeCaption: "caption", + }, + showAutoUpdate: true, + showExperimentalBookSourcesOption: false, + allowTeamCollectionEnabled: true, + aiSourceBubblesValidation: { + currentFingerprint: "", + validatedFingerprint: "", + succeeded: false, + message: "", + }, + }, +})); + +vi.mock("../utils/bloomApi", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + get: ( + endpoint: string, + callback: (result: { data: unknown }) => void, + ) => { + if (endpoint === "settings/advancedProgramSettings") { + callback({ data: initialAdvancedSettingsData }); + return; + } + + throw new Error(`Unexpected GET endpoint: ${endpoint}`); + }, + postJson: mockPostJson, + postJsonAsync: mockPostJsonAsync, + }; +}); + +vi.mock("../react_components/featureStatus", () => ({ + useGetFeatureStatus: () => ({ enabled: true }), +})); + +vi.mock("../react_components/l10nHooks", () => ({ + useL10n: (englishText: string) => englishText, +})); + +vi.mock("../react_components/requiresSubscription", () => ({ + BloomSubscriptionIndicatorIconAndText: () => null, +})); + +vi.mock("../utils/WireUpWinform", () => ({ + WireUpForWinforms: vi.fn(), +})); + +vi.mock("@sillsdev/config-r", () => ({ + ConfigrPane: (props: { + children: React.ReactNode; + initialValues: Record; + onChange: (settings: unknown) => void; + }) => { + configrPaneState.lastInitialValues = props.initialValues; + + return ( +
    + + + + {props.children} +
    + ); + }, + ConfigrPage: (props: React.PropsWithChildren) => ( +
    {props.children}
    + ), + ConfigrGroup: (props: React.PropsWithChildren) => ( +
    {props.children}
    + ), + ConfigrBoolean: () => null, + ConfigrInput: () => null, + ConfigrSelect: () => null, + ConfigrCustomObjectInput: (props: { + control: React.FunctionComponent<{ + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }>; + }) => { + const Control = props.control; + return {}} />; + }, + ConfigrCustomStringInput: (props: { + control: React.FunctionComponent<{ + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }>; + }) => { + const Control = props.control; + return {}} />; + }, +})); + +import { AdvancedSettingsPanel } from "./AdvancedSettingsPanel"; +import { parseSupportedTargetLanguageOptions } from "./AiSourceBubblesSettingsGroup"; + +describe("AdvancedSettingsPanel", () => { + let container: HTMLDivElement; + + const click = (selector: string) => { + const button = container.querySelector(selector) as HTMLButtonElement; + expect(button).not.toBeNull(); + act(() => { + button.click(); + }); + }; + + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.appendChild(container); + mockPostJson.mockReset(); + mockPostJsonAsync.mockReset(); + configrPaneState.lastInitialValues = undefined; + initialAdvancedSettingsData.values = { + autoUpdate: true, + showExperimentalBookSources: false, + allowTeamCollection: false, + allowAppBuilder: false, + allowAiSourceBubbles: false, + aiSourceBubblesProvider: "deepl", + aiSourceBubblesTargetLanguageTag: "en", + aiSourceBubblesDeepLApiKey: "", + aiSourceBubblesGoogleServiceAccountEmail: "", + aiSourceBubblesGooglePrivateKey: "", + showQrCode: true, + qrcodeCaption: "caption", + }; + initialAdvancedSettingsData.aiSourceBubblesValidation = { + currentFingerprint: "", + validatedFingerprint: "", + succeeded: false, + message: "", + }; + mockPostJsonAsync.mockImplementation(async (endpoint: string) => { + if (endpoint === "settings/validateAiSourceBubbles") { + return { + data: { + currentFingerprint: "fingerprint", + validatedFingerprint: "fingerprint", + succeeded: true, + message: "La lectura es importante", + }, + }; + } + + if (endpoint === "settings/aiSourceBubblesSupportedLanguages") { + return { + data: { + languages: [ + { Value: "es", Label: "Spanish" }, + { Value: "fra", Label: "French (fra)" }, + ], + }, + }; + } + + throw new Error(`Unexpected async POST endpoint: ${endpoint}`); + }); + }); + + afterEach(() => { + ReactDOM.unmountComponentAtNode(container); + container.remove(); + document.body.innerHTML = ""; + vi.useRealTimers(); + }); + + it("debounces AI validation and renders the translated probe result", async () => { + await act(async () => { + ReactDOM.render(, container); + }); + + click('[data-testid="set-google-config"]'); + + expect(mockPostJson).toHaveBeenCalledWith( + "settings/advancedProgramSettings", + expect.objectContaining({ + allowAiSourceBubbles: true, + aiSourceBubblesProvider: "google", + aiSourceBubblesTargetLanguageTag: "es", + aiSourceBubblesGoogleServiceAccountEmail: + "service-account@example.com", + }), + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(601); + }); + + expect(mockPostJsonAsync).toHaveBeenCalledWith( + "settings/validateAiSourceBubbles", + expect.objectContaining({ + allowAiSourceBubbles: true, + aiSourceBubblesProvider: "google", + aiSourceBubblesTargetLanguageTag: "es", + aiSourceBubblesGoogleServiceAccountEmail: + "service-account@example.com", + }), + ); + expect(container.textContent).toContain( + '"Today a reader, tomorrow a leader." --> La lectura es importante', + ); + + click('[data-testid="set-french-target"]'); + + expect(container.textContent).toContain("Testing translation..."); + + await act(async () => { + await vi.advanceTimersByTimeAsync(601); + }); + + expect(mockPostJsonAsync).toHaveBeenCalledWith( + "settings/validateAiSourceBubbles", + expect.objectContaining({ + allowAiSourceBubbles: true, + aiSourceBubblesProvider: "google", + aiSourceBubblesTargetLanguageTag: "fr", + aiSourceBubblesGoogleServiceAccountEmail: + "service-account@example.com", + }), + ); + }); + + it("clears the previous translation result immediately and waits for a target language before rerunning", async () => { + initialAdvancedSettingsData.values = { + ...initialAdvancedSettingsData.values, + allowAiSourceBubbles: true, + aiSourceBubblesProvider: "google", + aiSourceBubblesTargetLanguageTag: "es", + aiSourceBubblesGoogleServiceAccountEmail: + "service-account@example.com", + aiSourceBubblesGooglePrivateKey: + "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + }; + initialAdvancedSettingsData.aiSourceBubblesValidation = { + currentFingerprint: "loaded-fingerprint", + validatedFingerprint: "loaded-fingerprint", + succeeded: true, + message: "La lectura es importante", + }; + + await act(async () => { + ReactDOM.render(, container); + }); + + expect(container.textContent).toContain( + '"Today a reader, tomorrow a leader." --> La lectura es importante', + ); + + click('[data-testid="set-google-without-target"]'); + + expect(container.textContent).not.toContain( + '"Today a reader, tomorrow a leader." --> La lectura es importante', + ); + expect(container.textContent).not.toContain("Testing translation..."); + + await act(async () => { + await vi.advanceTimersByTimeAsync(601); + }); + + expect(mockPostJsonAsync).not.toHaveBeenCalledWith( + "settings/validateAiSourceBubbles", + expect.objectContaining({ + aiSourceBubblesProvider: "google", + aiSourceBubblesTargetLanguageTag: "", + }), + ); + + click('[data-testid="set-french-target"]'); + + expect(container.textContent).toContain("Testing translation..."); + + await act(async () => { + await vi.advanceTimersByTimeAsync(601); + }); + + expect(mockPostJsonAsync).toHaveBeenCalledWith( + "settings/validateAiSourceBubbles", + expect.objectContaining({ + allowAiSourceBubbles: true, + aiSourceBubblesProvider: "google", + aiSourceBubblesTargetLanguageTag: "fr", + aiSourceBubblesGoogleServiceAccountEmail: + "service-account@example.com", + }), + ); + }); + + it("fetches provider-supported languages for the target language selector", async () => { + await act(async () => { + ReactDOM.render(, container); + }); + + click('[data-testid="set-google-config"]'); + + const targetLanguageSelect = container.querySelector( + '[data-testid="ai-source-bubbles-target-language-select"]', + ) as HTMLElement; + expect(targetLanguageSelect).not.toBeNull(); + + await act(async () => { + targetLanguageSelect.dispatchEvent( + new Event("mousedown", { bubbles: true }), + ); + }); + + expect(mockPostJsonAsync).toHaveBeenCalledWith( + "settings/aiSourceBubblesSupportedLanguages", + expect.objectContaining({ + allowAiSourceBubbles: true, + aiSourceBubblesProvider: "google", + aiSourceBubblesGoogleServiceAccountEmail: + "service-account@example.com", + }), + ); + + expect( + parseSupportedTargetLanguageOptions({ + languages: [ + { Value: "es", Label: "Spanish (es)" } as unknown as { + value: string; + label: string; + }, + ], + }), + ).toEqual([{ value: "es", label: "Spanish" }]); + }); +}); diff --git a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx index 5c0d6d082533..67f675fa7457 100644 --- a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx +++ b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx @@ -2,71 +2,41 @@ import { css } from "@emotion/react"; import * as React from "react"; import { ConfigrBoolean, - ConfigrCustomObjectInput, ConfigrGroup, ConfigrInput, ConfigrPage, ConfigrPane, - ConfigrSelect, } from "@sillsdev/config-r"; -import { - defaultDisplayName, - parseLangtagFromLangChooser, -} from "@ethnolib/language-chooser-react-mui"; -import { MenuItem, TextField } from "@mui/material"; import { get, postJson } from "../utils/bloomApi"; import { kBloomBlue } from "../bloomMaterialUITheme"; import { tabMargins } from "./commonTabSettings"; -import { - ILanguageData, - showLanguageChooserDialog, -} from "./LanguageChooserDialog"; import { WireUpForWinforms } from "../utils/WireUpWinform"; -import { BloomSubscriptionIndicatorIconAndText } from "../react_components/requiresSubscription"; import { useGetFeatureStatus } from "../react_components/featureStatus"; +import { BloomSubscriptionIndicatorIconAndText } from "../react_components/requiresSubscription"; import { useL10n } from "../react_components/l10nHooks"; - -const kOtherTargetLanguageValue = "__other__"; - -interface ITargetLanguageOption { - value: string; - label: string; -} +import { + IAiSourceBubblesSettings, + IAiSourceBubblesValidationState, + useAiSourceBubblesSettingsGroup, +} from "./AiSourceBubblesSettingsGroup"; interface IAdvancedSettingsApiData { values: IAdvancedSettings; showAutoUpdate?: boolean; showExperimentalBookSourcesOption?: boolean; allowTeamCollectionEnabled?: boolean; - aiSourceBubblesKnownTargetLanguages?: ITargetLanguageOption[]; + aiSourceBubblesValidation?: IAiSourceBubblesValidationState; } -interface IAdvancedSettings { +interface IAdvancedSettings extends IAiSourceBubblesSettings { autoUpdate?: boolean; showExperimentalBookSources?: boolean; allowTeamCollection?: boolean; allowAppBuilder?: boolean; - allowAiSourceBubbles?: boolean; - aiSourceBubblesProvider?: string; - aiSourceBubblesTargetLanguageTag?: string; - aiSourceBubblesDeepLApiKey?: string; - aiSourceBubblesGoogleServiceAccountEmail?: string; - aiSourceBubblesGooglePrivateKey?: string; showQrCode?: boolean; qrcodeCaption?: string; } -function getLanguageOptionLabel(languageTag: string): string { - const parsedLanguage = parseLangtagFromLangChooser(languageTag); - const nameInScript = parsedLanguage?.script?.languageNameInScript; - const defaultName = - nameInScript || - (parsedLanguage?.language - ? defaultDisplayName(parsedLanguage.language) - : undefined); - return defaultName || languageTag; -} - export const AdvancedSettingsPanel: React.FunctionComponent = () => { const [settings, setSettings] = React.useState< IAdvancedSettings | undefined @@ -74,18 +44,12 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { const [showAutoUpdate, setShowAutoUpdate] = React.useState(false); const [allowTeamCollectionEnabled, setAllowTeamCollectionEnabled] = React.useState(false); - const [ - aiSourceBubblesKnownTargetLanguages, - setAiSourceBubblesKnownTargetLanguages, - ] = React.useState([]); - const [ - aiSourceBubblesCustomTargetLanguage, - setAiSourceBubblesCustomTargetLanguage, - ] = React.useState(); const [ showExperimentalBookSourcesOption, setShowExperimentalBookSourcesOption, ] = React.useState(false); + const [aiSourceBubblesValidation, setAiSourceBubblesValidation] = + React.useState(); const advancedProgramSettingsLabel = useL10n( "Advanced Program Settings", @@ -131,14 +95,6 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { "Target Language", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TargetLanguageLabel", ); - const aiSourceBubblesTargetLanguageDescription = useL10n( - "Choose one of this collection's languages, or Other... to select another language.", - "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TargetLanguageDescription", - ); - const aiSourceBubblesOtherLanguageLabel = useL10n( - "Other...", - "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.OtherLanguageLabel", - ); const aiSourceBubblesDeepLApiKeyLabel = useL10n( "DeepL API Key", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLApiKeyLabel", @@ -151,6 +107,10 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { "Google Service Account Private Key", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GooglePrivateKeyLabel", ); + const aiSourceBubblesTranslationTestLabel = useL10n( + "Translation Test", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TranslationTestLabel", + ); const qrCodesLabel = useL10n( "QR Codes", "CollectionSettingsDialog.AdvancedTab.QrCodes", @@ -202,102 +162,6 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { [], ); - const makeCustomTargetLanguageOption = React.useCallback( - (languageTag: string, displayName?: string): ITargetLanguageOption => { - const label = displayName || getLanguageOptionLabel(languageTag); - return { - value: languageTag, - label: `${label} (${languageTag})`, - }; - }, - [], - ); - - const getAiSourceBubblesTargetLanguageOptions = React.useCallback(() => { - const options = [...aiSourceBubblesKnownTargetLanguages]; - const selectedTargetLanguageTag = - settings?.aiSourceBubblesTargetLanguageTag?.trim(); - if ( - selectedTargetLanguageTag && - !options.some( - (option) => option.value === selectedTargetLanguageTag, - ) - ) { - const customOption = - aiSourceBubblesCustomTargetLanguage?.value === - selectedTargetLanguageTag - ? aiSourceBubblesCustomTargetLanguage - : makeCustomTargetLanguageOption(selectedTargetLanguageTag); - options.push(customOption); - } - - options.push({ - value: kOtherTargetLanguageValue, - label: aiSourceBubblesOtherLanguageLabel, - }); - - return options; - }, [ - aiSourceBubblesCustomTargetLanguage, - aiSourceBubblesKnownTargetLanguages, - aiSourceBubblesOtherLanguageLabel, - makeCustomTargetLanguageOption, - settings?.aiSourceBubblesTargetLanguageTag, - ]); - - const AiSourceBubblesTargetLanguageControl: React.FunctionComponent<{ - value: string; - disabled?: boolean; - onChange: (value: string) => void; - }> = (props) => { - return ( - { - const nextValue = event.target.value; - if (nextValue === kOtherTargetLanguageValue) { - const selectedTargetLanguageTag = - props.value || undefined; - showLanguageChooserDialog( - selectedTargetLanguageTag, - undefined, - (languageData: ILanguageData) => { - if (!languageData.LanguageTag) { - return; - } - - setAiSourceBubblesCustomTargetLanguage( - makeCustomTargetLanguageOption( - languageData.LanguageTag, - languageData.DesiredName || - languageData.DefaultName || - undefined, - ), - ); - props.onChange(languageData.LanguageTag); - }, - ); - return; - } - - props.onChange(nextValue); - }} - > - - {getAiSourceBubblesTargetLanguageOptions().map((option) => ( - - {option.label} - - ))} - - ); - }; - - // Load current advanced settings from the host dialog so Config-r starts with matching values. React.useEffect(() => { get("settings/advancedProgramSettings", (result) => { if (!result || !result.data || result.data === "{}") { @@ -308,8 +172,7 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { data = JSON.parse(result.data); } const advancedSettingsData = data as IAdvancedSettingsApiData; - const loadedSettings = advancedSettingsData.values; - setSettings(loadedSettings); + setSettings(advancedSettingsData.values); setShowAutoUpdate(advancedSettingsData.showAutoUpdate ?? false); setAllowTeamCollectionEnabled( advancedSettingsData.allowTeamCollectionEnabled ?? false, @@ -317,28 +180,24 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { setShowExperimentalBookSourcesOption( advancedSettingsData.showExperimentalBookSourcesOption ?? false, ); - setAiSourceBubblesKnownTargetLanguages( - advancedSettingsData.aiSourceBubblesKnownTargetLanguages ?? [], + setAiSourceBubblesValidation( + advancedSettingsData.aiSourceBubblesValidation, ); - if ( - loadedSettings?.aiSourceBubblesTargetLanguageTag && - !( - advancedSettingsData.aiSourceBubblesKnownTargetLanguages ?? - [] - ).some( - (option) => - option.value === - loadedSettings.aiSourceBubblesTargetLanguageTag, - ) - ) { - setAiSourceBubblesCustomTargetLanguage( - makeCustomTargetLanguageOption( - loadedSettings.aiSourceBubblesTargetLanguageTag, - ), - ); - } }); - }, [makeCustomTargetLanguageOption]); + }, []); + + const aiSourceBubblesSettingsGroup = useAiSourceBubblesSettingsGroup({ + settings, + initialValidation: aiSourceBubblesValidation, + groupLabel: aiSourceBubblesSectionLabel, + providerLabel: aiSourceBubblesProviderLabel, + targetLanguageLabel: aiSourceBubblesTargetLanguageLabel, + deepLApiKeyLabel: aiSourceBubblesDeepLApiKeyLabel, + googleServiceAccountEmailLabel: + aiSourceBubblesGoogleServiceAccountEmailLabel, + googlePrivateKeyLabel: aiSourceBubblesGooglePrivateKeyLabel, + translationTestLabel: aiSourceBubblesTranslationTestLabel, + }); return (
    {
    {
    - {settings.allowAiSourceBubbles && ( - - - - path="aiSourceBubblesTargetLanguageTag" - control={ - AiSourceBubblesTargetLanguageControl - } - label={aiSourceBubblesTargetLanguageLabel} - description={ - aiSourceBubblesTargetLanguageDescription - } - /> - {settings.aiSourceBubblesProvider === - "deepl" && ( - - )} - {settings.aiSourceBubblesProvider === - "google" && ( - <> - - - - )} - - )} + {settings.allowAiSourceBubbles && + aiSourceBubblesSettingsGroup} )} diff --git a/src/BloomBrowserUI/collection/AiSourceBubblesSettingsGroup.tsx b/src/BloomBrowserUI/collection/AiSourceBubblesSettingsGroup.tsx new file mode 100644 index 000000000000..5c9f7c922842 --- /dev/null +++ b/src/BloomBrowserUI/collection/AiSourceBubblesSettingsGroup.tsx @@ -0,0 +1,518 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { + ConfigrCustomObjectInput, + ConfigrGroup, + ConfigrInput, + ConfigrSelect, +} from "@sillsdev/config-r"; +import { MenuItem, TextField } from "@mui/material"; +import { postJsonAsync } from "../utils/bloomApi"; + +export interface ITargetLanguageOption { + value: string; + label: string; +} + +export interface IAiSourceBubblesValidationState { + currentFingerprint?: string; + validatedFingerprint?: string; + succeeded?: boolean; + message?: string; +} + +export interface IAiSourceBubblesSettings { + allowAiSourceBubbles?: boolean; + aiSourceBubblesProvider?: string; + aiSourceBubblesTargetLanguageTag?: string; + aiSourceBubblesDeepLApiKey?: string; + aiSourceBubblesGoogleServiceAccountEmail?: string; + aiSourceBubblesGooglePrivateKey?: string; +} + +interface IAiSourceBubblesSupportedLanguagesResponse { + languages?: ITargetLanguageOption[]; + message?: string; +} + +export function parseSupportedTargetLanguageOptions( + data?: IAiSourceBubblesSupportedLanguagesResponse, +): ITargetLanguageOption[] { + const rawLanguages = data?.languages; + if (!Array.isArray(rawLanguages)) { + return []; + } + + return rawLanguages + .map((language) => { + const candidate = language as { + value?: string; + label?: string; + Value?: string; + Label?: string; + }; + const value = candidate.value ?? candidate.Value ?? ""; + const rawLabel = candidate.label ?? candidate.Label ?? value; + const labelSuffix = ` (${value})`; + const label = rawLabel.endsWith(labelSuffix) + ? rawLabel.substring(0, rawLabel.length - labelSuffix.length) + : rawLabel; + + if (!value) { + return undefined; + } + + return { + value, + label, + }; + }) + .filter((language): language is ITargetLanguageOption => !!language); +} + +function hasAiSourceBubblesRequiredConfig( + settingsValue?: IAiSourceBubblesSettings, +): boolean { + if (!settingsValue?.allowAiSourceBubbles) { + return false; + } + + if (!settingsValue.aiSourceBubblesProvider) { + return false; + } + + if (settingsValue.aiSourceBubblesProvider === "google") { + return !!( + settingsValue.aiSourceBubblesGoogleServiceAccountEmail?.trim() && + settingsValue.aiSourceBubblesGooglePrivateKey?.trim() + ); + } + + return !!settingsValue.aiSourceBubblesDeepLApiKey?.trim(); +} + +function hasAiSourceBubblesRequiredValidationConfig( + settingsValue?: IAiSourceBubblesSettings, +): boolean { + return ( + hasAiSourceBubblesRequiredConfig(settingsValue) && + !!settingsValue?.aiSourceBubblesTargetLanguageTag?.trim() + ); +} + +function usesProviderManagedTargetLanguages( + settingsValue?: IAiSourceBubblesSettings, +): boolean { + return !!settingsValue?.aiSourceBubblesProvider; +} + +function getAiSourceBubblesProbeKey( + settingsValue?: IAiSourceBubblesSettings, +): string { + return JSON.stringify({ + allowAiSourceBubbles: settingsValue?.allowAiSourceBubbles ?? false, + aiSourceBubblesProvider: settingsValue?.aiSourceBubblesProvider ?? "", + aiSourceBubblesTargetLanguageTag: + settingsValue?.aiSourceBubblesTargetLanguageTag ?? "", + aiSourceBubblesDeepLApiKey: + settingsValue?.aiSourceBubblesDeepLApiKey ?? "", + aiSourceBubblesGoogleServiceAccountEmail: + settingsValue?.aiSourceBubblesGoogleServiceAccountEmail ?? "", + aiSourceBubblesGooglePrivateKey: + settingsValue?.aiSourceBubblesGooglePrivateKey ?? "", + }); +} + +function getAiSourceBubblesLanguageConfigKey( + settingsValue?: IAiSourceBubblesSettings, +): string { + return JSON.stringify({ + allowAiSourceBubbles: settingsValue?.allowAiSourceBubbles ?? false, + aiSourceBubblesProvider: settingsValue?.aiSourceBubblesProvider ?? "", + aiSourceBubblesDeepLApiKey: + settingsValue?.aiSourceBubblesDeepLApiKey ?? "", + aiSourceBubblesGoogleServiceAccountEmail: + settingsValue?.aiSourceBubblesGoogleServiceAccountEmail ?? "", + aiSourceBubblesGooglePrivateKey: + settingsValue?.aiSourceBubblesGooglePrivateKey ?? "", + }); +} + +function parseAiSourceBubblesValidationState( + data: unknown, +): IAiSourceBubblesValidationState | undefined { + if (!data || typeof data !== "object") { + return undefined; + } + + const candidate = data as { + currentFingerprint?: string; + CurrentFingerprint?: string; + validatedFingerprint?: string; + ValidatedFingerprint?: string; + succeeded?: boolean; + Succeeded?: boolean; + message?: string; + Message?: string; + configurationFingerprint?: string; + ConfigurationFingerprint?: string; + }; + return { + currentFingerprint: + candidate.currentFingerprint || + candidate.CurrentFingerprint || + candidate.configurationFingerprint || + candidate.ConfigurationFingerprint, + validatedFingerprint: + candidate.validatedFingerprint || + candidate.ValidatedFingerprint || + candidate.configurationFingerprint || + candidate.ConfigurationFingerprint, + succeeded: candidate.succeeded ?? candidate.Succeeded, + message: candidate.message || candidate.Message, + }; +} + +export const useAiSourceBubblesSettingsGroup = (props: { + settings: IAiSourceBubblesSettings | undefined; + initialValidation?: IAiSourceBubblesValidationState; + groupLabel: string; + providerLabel: string; + targetLanguageLabel: string; + deepLApiKeyLabel: string; + googleServiceAccountEmailLabel: string; + googlePrivateKeyLabel: string; + translationTestLabel: string; +}): React.ReactElement => { + const [aiSourceBubblesValidation, setAiSourceBubblesValidation] = + React.useState( + props.initialValidation, + ); + const [ + isAiSourceBubblesValidationPending, + setIsAiSourceBubblesValidationPending, + ] = React.useState(false); + const [supportedTargetLanguages, setSupportedTargetLanguages] = + React.useState([]); + const [supportedLanguagesMessage, setSupportedLanguagesMessage] = + React.useState(""); + const [isLoadingSupportedLanguages, setIsLoadingSupportedLanguages] = + React.useState(false); + const [languageOptionsVersion, setLanguageOptionsVersion] = + React.useState(0); + const lastAiSourceBubblesProbeKeyRef = React.useRef(""); + const lastSupportedLanguagesConfigKeyRef = React.useRef(""); + const latestSettingsRef = React.useRef(props.settings); + latestSettingsRef.current = props.settings; + + React.useEffect(() => { + setAiSourceBubblesValidation(props.initialValidation); + const loadedProbeKey = getAiSourceBubblesProbeKey( + latestSettingsRef.current, + ); + if ( + props.initialValidation?.validatedFingerprint && + props.initialValidation.validatedFingerprint === + props.initialValidation.currentFingerprint && + props.initialValidation.message + ) { + lastAiSourceBubblesProbeKeyRef.current = loadedProbeKey; + } else { + lastAiSourceBubblesProbeKeyRef.current = ""; + } + }, [props.initialValidation]); + + const loadSupportedLanguages = React.useCallback(async () => { + const languageConfigKey = getAiSourceBubblesLanguageConfigKey( + props.settings, + ); + if (!usesProviderManagedTargetLanguages(props.settings)) { + setSupportedTargetLanguages([]); + setSupportedLanguagesMessage(""); + lastSupportedLanguagesConfigKeyRef.current = languageConfigKey; + return; + } + + if (!hasAiSourceBubblesRequiredConfig(props.settings)) { + setSupportedTargetLanguages([]); + setSupportedLanguagesMessage(""); + lastSupportedLanguagesConfigKeyRef.current = ""; + return; + } + + if ( + languageConfigKey === lastSupportedLanguagesConfigKeyRef.current && + supportedTargetLanguages.length > 0 + ) { + return; + } + + setIsLoadingSupportedLanguages(true); + setSupportedLanguagesMessage(""); + try { + const response = await postJsonAsync( + "settings/aiSourceBubblesSupportedLanguages", + props.settings, + ); + const data = response?.data as + | IAiSourceBubblesSupportedLanguagesResponse + | undefined; + const languages = parseSupportedTargetLanguageOptions(data); + setSupportedTargetLanguages(languages); + setSupportedLanguagesMessage(data?.message ?? ""); + lastSupportedLanguagesConfigKeyRef.current = languageConfigKey; + setLanguageOptionsVersion((value) => value + 1); + } finally { + setIsLoadingSupportedLanguages(false); + } + }, [props.settings, supportedTargetLanguages.length]); + + React.useEffect(() => { + const currentLanguageConfigKey = getAiSourceBubblesLanguageConfigKey( + props.settings, + ); + if ( + currentLanguageConfigKey !== + lastSupportedLanguagesConfigKeyRef.current + ) { + setSupportedTargetLanguages([]); + setSupportedLanguagesMessage(""); + } + }, [props.settings]); + + // This effect is necessary because provider-backed target-language lists depend on + // external credentials and should be ready as soon as the current provider config is usable. + React.useEffect(() => { + if (!hasAiSourceBubblesRequiredConfig(props.settings)) { + return; + } + + void loadSupportedLanguages(); + }, [loadSupportedLanguages, props.settings]); + + // This effect is necessary because validation must synchronize the current Settings form values + // with the backend/provider after the user stops typing relevant AI configuration fields. + React.useEffect(() => { + if (!props.settings?.allowAiSourceBubbles) { + setIsAiSourceBubblesValidationPending(false); + return; + } + + const probeKey = getAiSourceBubblesProbeKey(props.settings); + if (probeKey === lastAiSourceBubblesProbeKeyRef.current) { + return; + } + + lastAiSourceBubblesProbeKeyRef.current = probeKey; + if (!hasAiSourceBubblesRequiredValidationConfig(props.settings)) { + setIsAiSourceBubblesValidationPending(false); + setAiSourceBubblesValidation(undefined); + return; + } + + setIsAiSourceBubblesValidationPending(true); + setAiSourceBubblesValidation(undefined); + + let cancelled = false; + const timeoutId = window.setTimeout(() => { + void (async () => { + try { + const response = await postJsonAsync( + "settings/validateAiSourceBubbles", + props.settings, + ); + if (cancelled) { + return; + } + + setAiSourceBubblesValidation( + parseAiSourceBubblesValidationState(response?.data), + ); + } finally { + if (!cancelled) { + setIsAiSourceBubblesValidationPending(false); + } + } + })(); + }, 600); + + return () => { + cancelled = true; + window.clearTimeout(timeoutId); + }; + }, [props.settings]); + + const AiSourceBubblesTargetLanguageControl: React.FunctionComponent<{ + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }> = (controlProps) => { + if (!usesProviderManagedTargetLanguages(props.settings)) { + return ( + { + controlProps.onChange(event.target.value); + }} + inputProps={{ + "data-testid": + "ai-source-bubbles-target-language-input", + }} + /> + ); + } + + const currentValue = controlProps.value || ""; + const knownOptions = supportedTargetLanguages.some( + (option) => option.value === currentValue, + ) + ? supportedTargetLanguages + : currentValue + ? [ + ...supportedTargetLanguages, + { + value: currentValue, + label: currentValue, + }, + ] + : supportedTargetLanguages; + + return ( + { + controlProps.onChange(event.target.value); + }} + SelectProps={{ + onOpen: () => { + void loadSupportedLanguages(); + }, + }} + inputProps={{ + "data-testid": "ai-source-bubbles-target-language-select", + "data-language-options-version": languageOptionsVersion, + }} + > + + {isLoadingSupportedLanguages && ( + + Loading languages... + + )} + {knownOptions.map((option) => ( + + {option.label} + + ))} + + ); + }; + + const validationText = React.useMemo(() => { + if (isAiSourceBubblesValidationPending) { + return { + text: "Testing translation...", + color: "#555", + }; + } + + if (!aiSourceBubblesValidation?.message) { + return { + text: "", + color: "#555", + }; + } + + return { + text: aiSourceBubblesValidation.succeeded + ? `\"Today a reader, tomorrow a leader.\" --> ${aiSourceBubblesValidation.message}` + : `Translation test failed: ${aiSourceBubblesValidation.message}`, + color: aiSourceBubblesValidation.succeeded ? "#2e7d32" : "#b3261e", + }; + }, [aiSourceBubblesValidation, isAiSourceBubblesValidationPending]); + + const AiSourceBubblesValidationStatusControl: React.FunctionComponent<{ + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }> = () => { + return ( +
    + {validationText.text} +
    + ); + }; + + return ( + + + {props.settings?.aiSourceBubblesProvider === "deepl" && ( + + )} + {props.settings?.aiSourceBubblesProvider === "google" && ( + <> + + + + )} + + path="aiSourceBubblesTargetLanguageTag" + control={AiSourceBubblesTargetLanguageControl} + label={props.targetLanguageLabel} + /> + + path="aiSourceBubblesValidationMessage" + control={AiSourceBubblesValidationStatusControl} + label={props.translationTestLabel} + overrideValue={`${languageOptionsVersion}:${validationText.text}`} + /> + + ); +}; diff --git a/src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs b/src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs index 986a592af2d1..86ba32010ac6 100644 --- a/src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs +++ b/src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -35,25 +37,58 @@ public class AiSourceBubblesTranslateResponse public string Text { get; set; } } + /// + /// Result of validating the current AI Source Bubbles configuration. + /// + public class AiSourceBubblesValidationResult + { + public bool Succeeded { get; set; } + public string ConfigurationFingerprint { get; set; } + public string Message { get; set; } + } + + /// + /// Option surfaced to the Collection Settings target-language picker. + /// + public class AiSourceBubblesTargetLanguageOption + { + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("label")] + public string Label { get; set; } + } + /// /// Coordinates collection-backed AI Source Bubbles translation. /// public class AiSourceBubblesService { + public const string kValidationProbeText = "Today a reader, tomorrow a leader."; + public const string kValidationProbeSourceLanguageTag = "en"; + private static readonly HttpClient _httpClient = new HttpClient(); private readonly CollectionSettings _collectionSettings; private readonly Dictionary _providers; public AiSourceBubblesService(CollectionSettings collectionSettings) + : this(collectionSettings, null) { } + + internal AiSourceBubblesService( + CollectionSettings collectionSettings, + Dictionary providers + ) { _collectionSettings = collectionSettings; - _providers = new Dictionary( - StringComparer.OrdinalIgnoreCase - ) - { - { "deepl", new DeepLAiSourceBubblesTranslationProvider() }, - { "google", new GoogleAiSourceBubblesTranslationProvider() }, - }; + _providers = + providers + ?? new Dictionary( + StringComparer.OrdinalIgnoreCase + ) + { + { "deepl", new DeepLAiSourceBubblesTranslationProvider() }, + { "google", new GoogleAiSourceBubblesTranslationProvider() }, + }; } /// @@ -62,21 +97,68 @@ public AiSourceBubblesService(CollectionSettings collectionSettings) public async Task TranslateAsync( AiSourceBubblesTranslateRequest request ) + { + return await TranslateAsync(request, true); + } + + /// + /// Validates the configured provider, credentials, and target language with a probe translation. + /// + public async Task ValidateConfigurationAsync() + { + var response = await TranslateAsync( + new AiSourceBubblesTranslateRequest + { + SourceText = kValidationProbeText, + SourceLanguageTag = kValidationProbeSourceLanguageTag, + }, + false + ); + + return new AiSourceBubblesValidationResult + { + Succeeded = true, + ConfigurationFingerprint = GetConfigurationFingerprint(_collectionSettings), + Message = response.Text, + }; + } + + /// + /// Gets the target languages currently supported by the configured provider. + /// + public async Task< + List + > GetSupportedTargetLanguagesAsync() + { + var provider = GetSelectedProvider(); + return await provider.GetSupportedTargetLanguagesAsync( + _collectionSettings, + _httpClient + ); + } + + private async Task TranslateAsync( + AiSourceBubblesTranslateRequest request, + bool requireFeatureEnabled + ) { if (request == null) throw new ArgumentNullException(nameof(request)); if (string.IsNullOrWhiteSpace(request.SourceText)) throw new ArgumentException("Source text is required.", nameof(request)); - var featureStatus = FeatureStatus.GetFeatureStatus( - _collectionSettings.Subscription, - FeatureName.AiSourceBubbles - ); - if (!featureStatus.Visible || !featureStatus.Enabled) + if (requireFeatureEnabled) { - throw new InvalidOperationException( - "AI Source Bubbles is not enabled for this collection." + var featureStatus = FeatureStatus.GetFeatureStatus( + _collectionSettings.Subscription, + FeatureName.AiSourceBubbles ); + if (!featureStatus.Visible || !featureStatus.Enabled) + { + throw new InvalidOperationException( + "AI Source Bubbles is not enabled for this collection." + ); + } } var provider = GetSelectedProvider(); @@ -90,21 +172,81 @@ AiSourceBubblesTranslateRequest request ); } - var translatedText = await provider.TranslateAsync( - _collectionSettings, - request.SourceText, + var startedAt = DateTimeOffset.Now; + var stopwatch = Stopwatch.StartNew(); + WriteTranslationActivity( + "request", + startedAt, + provider.ProviderId, request.SourceLanguageTag, targetLanguageTag, - _httpClient + request.SourceText ); - return new AiSourceBubblesTranslateResponse + try { - ProviderId = provider.ProviderId, - TargetLanguageTag = targetLanguageTag, - AiLanguageTag = GetAiLanguageTag(targetLanguageTag, provider.ProviderId), - Text = translatedText, - }; + var translatedText = await provider.TranslateAsync( + _collectionSettings, + request.SourceText, + request.SourceLanguageTag, + targetLanguageTag, + _httpClient + ); + stopwatch.Stop(); + WriteTranslationActivity( + "response", + DateTimeOffset.Now, + provider.ProviderId, + request.SourceLanguageTag, + targetLanguageTag, + request.SourceText, + translatedText, + stopwatch.Elapsed + ); + + return new AiSourceBubblesTranslateResponse + { + ProviderId = provider.ProviderId, + TargetLanguageTag = targetLanguageTag, + AiLanguageTag = GetAiLanguageTag(targetLanguageTag, provider.ProviderId), + Text = translatedText, + }; + } + catch (Exception exception) + { + stopwatch.Stop(); + Console.WriteLine( + $"[AiSourceBubbles][response] time={DateTimeOffset.Now:O} provider={provider.ProviderId} sourceLanguage={request.SourceLanguageTag} targetLanguage={targetLanguageTag} elapsedMs={stopwatch.ElapsedMilliseconds} input={JsonConvert.ToString(request.SourceText)} error={JsonConvert.ToString(exception.Message)}" + ); + throw; + } + } + + private static void WriteTranslationActivity( + string stage, + DateTimeOffset time, + string providerId, + string sourceLanguageTag, + string targetLanguageTag, + string sourceText, + string translatedText = null, + TimeSpan? elapsed = null + ) + { + var logLine = + $"[AiSourceBubbles][{stage}] time={time:O} provider={providerId} sourceLanguage={sourceLanguageTag} targetLanguage={targetLanguageTag} input={JsonConvert.ToString(sourceText)}"; + + if (translatedText != null) + { + logLine += $" output={JsonConvert.ToString(translatedText)}"; + } + + if (elapsed.HasValue) + { + logLine += $" elapsedMs={elapsed.Value.TotalMilliseconds:F0}"; + } + + Console.WriteLine(logLine); } /// @@ -134,7 +276,13 @@ public static string NormalizeProviderId(string providerId) return string.Empty; var normalized = providerId.Trim().ToLowerInvariant(); - return normalized == "googletranslate" ? "google" : normalized; + return normalized switch + { + "alpha-2" => string.Empty, + "alpha2" => string.Empty, + "googletranslate" => "google", + _ => normalized, + }; } /// @@ -175,6 +323,88 @@ out var variant return language; } + /// + /// Extracts the Google Cloud project id from a service account email. + /// + public static string GetGoogleProjectIdFromServiceAccountEmail(string serviceAccountEmail) + { + if (string.IsNullOrWhiteSpace(serviceAccountEmail)) + { + throw new InvalidOperationException( + "Set a Google service account email in Collection Settings > AI Source Bubbles." + ); + } + + var trimmedEmail = serviceAccountEmail.Trim(); + var atIndex = trimmedEmail.IndexOf('@'); + if (atIndex < 0 || atIndex == trimmedEmail.Length - 1) + { + throw new InvalidOperationException( + "Google service account email is not in the expected format." + ); + } + + var domain = trimmedEmail.Substring(atIndex + 1); + const string kExpectedSuffix = ".iam.gserviceaccount.com"; + if (!domain.EndsWith(kExpectedSuffix, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Google service account email must end with .iam.gserviceaccount.com." + ); + } + + var projectId = domain.Substring(0, domain.Length - kExpectedSuffix.Length); + if (string.IsNullOrWhiteSpace(projectId)) + { + throw new InvalidOperationException( + "Google service account email does not contain a project id." + ); + } + + return projectId; + } + + /// + /// Builds a stable fingerprint of the AI Source Bubbles configuration without storing raw secrets. + /// + public static string GetConfigurationFingerprint(CollectionSettings collectionSettings) + { + if (collectionSettings == null) + throw new ArgumentNullException(nameof(collectionSettings)); + + return GetConfigurationFingerprint( + collectionSettings.AiSourceBubblesProviderId, + collectionSettings.AiSourceBubblesTargetLanguageTag, + collectionSettings.AiSourceBubblesDeepLApiKey, + collectionSettings.AiSourceBubblesGoogleServiceAccountEmail, + collectionSettings.AiSourceBubblesGooglePrivateKey + ); + } + + /// + /// Builds a stable fingerprint of provider, target language, and provider-specific credentials. + /// + public static string GetConfigurationFingerprint( + string providerId, + string targetLanguageTag, + string deepLApiKey, + string googleServiceAccountEmail, + string googlePrivateKey + ) + { + var normalizedProvider = NormalizeProviderId(providerId); + var normalizedTargetLanguageTag = NormalizeBloomLanguageTag(targetLanguageTag); + var credentialKey = normalizedProvider switch + { + "google" => + $"{googleServiceAccountEmail?.Trim()}\n{AiSourceBubblesProviderHelpers.NormalizeGooglePrivateKey(googlePrivateKey)}", + _ => deepLApiKey?.Trim() ?? string.Empty, + }; + var fingerprintInput = + $"{normalizedProvider}\n{normalizedTargetLanguageTag}\n{credentialKey}"; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(fingerprintInput))); + } + private IAiSourceBubblesTranslationProvider GetSelectedProvider() { var providerId = NormalizeProviderId(_collectionSettings.AiSourceBubblesProviderId); @@ -198,6 +428,11 @@ internal interface IAiSourceBubblesTranslationProvider { string ProviderId { get; } + Task> GetSupportedTargetLanguagesAsync( + CollectionSettings collectionSettings, + HttpClient httpClient + ); + Task TranslateAsync( CollectionSettings collectionSettings, string sourceText, @@ -212,6 +447,64 @@ internal sealed class DeepLAiSourceBubblesTranslationProvider { public string ProviderId => "deepl"; + public async Task< + List + > GetSupportedTargetLanguagesAsync( + CollectionSettings collectionSettings, + HttpClient httpClient + ) + { + if (string.IsNullOrWhiteSpace(collectionSettings.AiSourceBubblesDeepLApiKey)) + { + throw new InvalidOperationException( + "Set a DeepL API key in Collection Settings > AI Source Bubbles." + ); + } + + using var request = new HttpRequestMessage( + HttpMethod.Get, + GetApiBaseUrl(collectionSettings.AiSourceBubblesDeepLApiKey) + + "/v2/languages?type=target" + ); + request.Headers.Authorization = new AuthenticationHeaderValue( + "DeepL-Auth-Key", + collectionSettings.AiSourceBubblesDeepLApiKey.Trim() + ); + + using var response = await httpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + AiSourceBubblesProviderHelpers.EnsureSuccess(response, responseContent, "DeepL"); + + var languages = JArray.Parse(responseContent); + var options = new List(); + foreach (var languageToken in languages) + { + var languageCode = languageToken["language"]?.Value(); + if (string.IsNullOrWhiteSpace(languageCode)) + { + continue; + } + + var normalizedLanguageCode = AiSourceBubblesService.NormalizeBloomLanguageTag( + languageCode + ); + var name = languageToken["name"]?.Value() ?? normalizedLanguageCode; + options.Add( + new AiSourceBubblesTargetLanguageOption + { + Value = normalizedLanguageCode, + Label = name, + } + ); + } + + options.Sort( + (first, second) => + StringComparer.CurrentCultureIgnoreCase.Compare(first.Label, second.Label) + ); + return options; + } + public async Task TranslateAsync( CollectionSettings collectionSettings, string sourceText, @@ -263,10 +556,15 @@ HttpClient httpClient } private static string GetEndpoint(string apiKey) + { + return GetApiBaseUrl(apiKey) + "/v2/translate"; + } + + private static string GetApiBaseUrl(string apiKey) { return apiKey.Trim().EndsWith(":fx", StringComparison.OrdinalIgnoreCase) - ? "https://api-free.deepl.com/v2/translate" - : "https://api.deepl.com/v2/translate"; + ? "https://api-free.deepl.com" + : "https://api.deepl.com"; } private static string NormalizeDeepLLanguageTag(string languageTag) @@ -285,34 +583,91 @@ internal sealed class GoogleAiSourceBubblesTranslationProvider private const string kTokenEndpoint = "https://oauth2.googleapis.com/token"; private const string kTranslateEndpoint = "https://translation.googleapis.com/language/translate/v2"; + private const string kSupportedLanguagesEndpointTemplate = + "https://translation.googleapis.com/v3/projects/{0}/locations/global/supportedLanguages?display_language_code=en"; public string ProviderId => "google"; - public async Task TranslateAsync( + public async Task< + List + > GetSupportedTargetLanguagesAsync( CollectionSettings collectionSettings, - string sourceText, - string sourceLanguageTag, - string targetLanguageTag, HttpClient httpClient ) { - if ( - string.IsNullOrWhiteSpace( - collectionSettings.AiSourceBubblesGoogleServiceAccountEmail - ) - ) + EnsureGoogleCredentials(collectionSettings); + + var accessToken = await GetAccessTokenAsync(collectionSettings, httpClient); + var projectId = AiSourceBubblesService.GetGoogleProjectIdFromServiceAccountEmail( + collectionSettings.AiSourceBubblesGoogleServiceAccountEmail + ); + using var request = new HttpRequestMessage( + HttpMethod.Get, + string.Format(kSupportedLanguagesEndpointTemplate, Uri.EscapeDataString(projectId)) + ); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await httpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + AiSourceBubblesProviderHelpers.EnsureSuccess( + response, + responseContent, + "Google Translate" + ); + + var responseJson = JObject.Parse(responseContent); + var languages = responseJson["languages"] as JArray; + var options = new List(); + if (languages == null) { - throw new InvalidOperationException( - "Set a Google service account email in Collection Settings > AI Source Bubbles." - ); + return options; } - if (string.IsNullOrWhiteSpace(collectionSettings.AiSourceBubblesGooglePrivateKey)) + + foreach (var languageToken in languages) { - throw new InvalidOperationException( - "Set a Google service account private key in Collection Settings > AI Source Bubbles." + var supportsTarget = languageToken["supportTarget"]?.Value() ?? false; + if (!supportsTarget) + { + continue; + } + + var languageCode = languageToken["languageCode"]?.Value(); + if (string.IsNullOrWhiteSpace(languageCode)) + { + continue; + } + + var normalizedLanguageCode = AiSourceBubblesService.NormalizeBloomLanguageTag( + languageCode + ); + var displayName = + languageToken["displayName"]?.Value() ?? normalizedLanguageCode; + options.Add( + new AiSourceBubblesTargetLanguageOption + { + Value = normalizedLanguageCode, + Label = displayName, + } ); } + options.Sort( + (first, second) => + StringComparer.CurrentCultureIgnoreCase.Compare(first.Label, second.Label) + ); + return options; + } + + public async Task TranslateAsync( + CollectionSettings collectionSettings, + string sourceText, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient + ) + { + EnsureGoogleCredentials(collectionSettings); + var accessToken = await GetAccessTokenAsync(collectionSettings, httpClient); var fields = new List> { @@ -355,6 +710,26 @@ HttpClient httpClient return WebUtility.HtmlDecode(translatedText); } + private static void EnsureGoogleCredentials(CollectionSettings collectionSettings) + { + if ( + string.IsNullOrWhiteSpace( + collectionSettings.AiSourceBubblesGoogleServiceAccountEmail + ) + ) + { + throw new InvalidOperationException( + "Set a Google service account email in Collection Settings > AI Source Bubbles." + ); + } + if (string.IsNullOrWhiteSpace(collectionSettings.AiSourceBubblesGooglePrivateKey)) + { + throw new InvalidOperationException( + "Set a Google service account private key in Collection Settings > AI Source Bubbles." + ); + } + } + private static async Task GetAccessTokenAsync( CollectionSettings collectionSettings, HttpClient httpClient @@ -419,7 +794,9 @@ HttpClient httpClient private static string SignJwt(string signingInput, string privateKey) { using var rsa = RSA.Create(); - rsa.ImportFromPem(NormalizePrivateKey(privateKey).ToCharArray()); + rsa.ImportFromPem( + AiSourceBubblesProviderHelpers.NormalizeGooglePrivateKey(privateKey).ToCharArray() + ); var signature = rsa.SignData( Encoding.UTF8.GetBytes(signingInput), HashAlgorithmName.SHA256, @@ -427,15 +804,15 @@ private static string SignJwt(string signingInput, string privateKey) ); return AiSourceBubblesProviderHelpers.Base64UrlEncode(signature); } - - private static string NormalizePrivateKey(string privateKey) - { - return privateKey.Replace("\\r", "").Replace("\\n", "\n").Trim(); - } } internal static class AiSourceBubblesProviderHelpers { + internal static string NormalizeGooglePrivateKey(string privateKey) + { + return (privateKey ?? string.Empty).Replace("\\r", "").Replace("\\n", "\n").Trim(); + } + internal static void EnsureSuccess( HttpResponseMessage response, string responseContent, @@ -453,6 +830,7 @@ string providerName json["message"]?.Value() ?? json["error"]?.Value() ?? json["error"]?["message"]?.Value() + ?? json["detail"]?[0]?["msg"]?.Value() ?? responseContent; } catch diff --git a/src/BloomExe/Book/RuntimeInformationInjector.cs b/src/BloomExe/Book/RuntimeInformationInjector.cs index 3984117c3db2..2c52fb44aa52 100644 --- a/src/BloomExe/Book/RuntimeInformationInjector.cs +++ b/src/BloomExe/Book/RuntimeInformationInjector.cs @@ -502,9 +502,20 @@ IFileLocator fileLocator var aiSourceBubblesTargetLanguageTag = AiSourceBubblesService.NormalizeBloomLanguageTag( bookData.CollectionSettings.AiSourceBubblesTargetLanguageTag ); + var aiSourceBubblesConfigurationFingerprint = + AiSourceBubblesService.GetConfigurationFingerprint(bookData.CollectionSettings); + var aiSourceBubblesConfigurationValid = + bookData.CollectionSettings.AiSourceBubblesLastValidationSucceeded + && String.Equals( + bookData.CollectionSettings.AiSourceBubblesValidatedConfigurationFingerprint, + aiSourceBubblesConfigurationFingerprint, + StringComparison.Ordinal + ); d.Add( "allowAiSourceBubbles", - aiSourceBubblesFeatureStatus.Visible && aiSourceBubblesFeatureStatus.Enabled + aiSourceBubblesFeatureStatus.Visible + && aiSourceBubblesFeatureStatus.Enabled + && aiSourceBubblesConfigurationValid ); d.Add("aiSourceBubblesProvider", aiSourceBubblesProviderId); d.Add("aiSourceBubblesTargetLanguageTag", aiSourceBubblesTargetLanguageTag); diff --git a/src/BloomExe/Collection/CollectionSettings.cs b/src/BloomExe/Collection/CollectionSettings.cs index fa2e09977ac3..3cf8f921599b 100644 --- a/src/BloomExe/Collection/CollectionSettings.cs +++ b/src/BloomExe/Collection/CollectionSettings.cs @@ -77,6 +77,9 @@ public class CollectionSettings public string AiSourceBubblesDeepLApiKey = ""; public string AiSourceBubblesGoogleServiceAccountEmail = ""; public string AiSourceBubblesGooglePrivateKey = ""; + public string AiSourceBubblesValidatedConfigurationFingerprint = ""; + public bool AiSourceBubblesLastValidationSucceeded = false; + public string AiSourceBubblesLastValidationMessage = ""; public static readonly Dictionary CssNumberStylesToCultureOrDigits = new Dictionary() @@ -420,6 +423,24 @@ public void Save() xml.Add( new XElement("AiSourceBubblesGooglePrivateKey", AiSourceBubblesGooglePrivateKey) ); + xml.Add( + new XElement( + "AiSourceBubblesValidatedConfigurationFingerprint", + AiSourceBubblesValidatedConfigurationFingerprint + ) + ); + xml.Add( + new XElement( + "AiSourceBubblesLastValidationSucceeded", + AiSourceBubblesLastValidationSucceeded + ) + ); + xml.Add( + new XElement( + "AiSourceBubblesLastValidationMessage", + AiSourceBubblesLastValidationMessage + ) + ); RobustIO.SaveXElement(xml, SettingsFilePath); // Color palette settings are stored in a separate Json file @@ -711,6 +732,21 @@ public void Load() "AiSourceBubblesGooglePrivateKey", "" ); + AiSourceBubblesValidatedConfigurationFingerprint = ReadString( + xml, + "AiSourceBubblesValidatedConfigurationFingerprint", + "" + ); + AiSourceBubblesLastValidationSucceeded = ReadBoolean( + xml, + "AiSourceBubblesLastValidationSucceeded", + false + ); + AiSourceBubblesLastValidationMessage = ReadString( + xml, + "AiSourceBubblesLastValidationMessage", + "" + ); LoadDictionary(xml, "Palette", ColorPalettes); } diff --git a/src/BloomExe/Collection/CollectionSettingsDialog.cs b/src/BloomExe/Collection/CollectionSettingsDialog.cs index 9bfaeefdeca8..c12da5a3ed2c 100644 --- a/src/BloomExe/Collection/CollectionSettingsDialog.cs +++ b/src/BloomExe/Collection/CollectionSettingsDialog.cs @@ -58,6 +58,9 @@ public string PendingDefaultBookshelf internal string PendingAiSourceBubblesDeepLApiKey; internal string PendingAiSourceBubblesGoogleServiceAccountEmail; internal string PendingAiSourceBubblesGooglePrivateKey; + internal string PendingAiSourceBubblesValidatedConfigurationFingerprint; + internal bool PendingAiSourceBubblesLastValidationSucceeded; + internal string PendingAiSourceBubblesLastValidationMessage; internal bool AllowTeamCollectionOptionEnabled = false; // "Internal" so CollectionSettingsApi can update these. @@ -141,6 +144,12 @@ XMatterPackFinder xmatterPackFinder _collectionSettings.AiSourceBubblesGoogleServiceAccountEmail; PendingAiSourceBubblesGooglePrivateKey = _collectionSettings.AiSourceBubblesGooglePrivateKey; + PendingAiSourceBubblesValidatedConfigurationFingerprint = + _collectionSettings.AiSourceBubblesValidatedConfigurationFingerprint; + PendingAiSourceBubblesLastValidationSucceeded = + _collectionSettings.AiSourceBubblesLastValidationSucceeded; + PendingAiSourceBubblesLastValidationMessage = + _collectionSettings.AiSourceBubblesLastValidationMessage; if ( !ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kTeamCollections) @@ -440,6 +449,12 @@ private void _okButton_Click(object sender, EventArgs e) PendingAiSourceBubblesGoogleServiceAccountEmail; _collectionSettings.AiSourceBubblesGooglePrivateKey = PendingAiSourceBubblesGooglePrivateKey; + _collectionSettings.AiSourceBubblesValidatedConfigurationFingerprint = + PendingAiSourceBubblesValidatedConfigurationFingerprint; + _collectionSettings.AiSourceBubblesLastValidationSucceeded = + PendingAiSourceBubblesLastValidationSucceeded; + _collectionSettings.AiSourceBubblesLastValidationMessage = + PendingAiSourceBubblesLastValidationMessage; _collectionSettings.Country = _countryText.Text.Trim(); _collectionSettings.Province = _provinceText.Text.Trim(); diff --git a/src/BloomExe/web/controllers/CollectionSettingsApi.cs b/src/BloomExe/web/controllers/CollectionSettingsApi.cs index df1e3820b145..f3cc28641d1e 100644 --- a/src/BloomExe/web/controllers/CollectionSettingsApi.cs +++ b/src/BloomExe/web/controllers/CollectionSettingsApi.cs @@ -2,7 +2,11 @@ using System.Collections.Generic; using System.Dynamic; using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; using System.Text; +using System.Threading.Tasks; using Bloom.AiSourceBubbles; using Bloom.Api; using Bloom.Book; @@ -81,13 +85,28 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) var dialog = DialogBeingEdited; if (dialog != null) { - StoreAdvancedSettingsData(request, dialog); + StoreAdvancedSettingsData( + JObject.Parse(request.RequiredPostJson()), + dialog + ); } request.PostSucceeded(); } }, true ); + apiHandler.RegisterAsyncEndpointHandler( + kApiUrlPart + "validateAiSourceBubbles", + HandleValidateAiSourceBubblesAsync, + false, + true + ); + apiHandler.RegisterAsyncEndpointHandler( + kApiUrlPart + "aiSourceBubblesSupportedLanguages", + HandleGetAiSourceBubblesSupportedLanguagesAsync, + false, + true + ); apiHandler.RegisterBooleanEndpointHandler( kApiUrlPart + "lockedToOneDownloadedBook", request => _collectionSettings.EditingABlorgBook, @@ -303,6 +322,7 @@ private object GetAdvancedSettingsData() var isAutoUpdateSupported = dialog?.ShowAutomaticallyUpdateOption ?? CollectionSettingsDialog.AutoUpdateSupportedOnThisPlatform; + var aiSourceBubblesValidation = GetAiSourceBubblesValidationState(dialog); return new { values = new @@ -344,55 +364,220 @@ private object GetAdvancedSettingsData() showExperimentalBookSourcesOption = dialog?.ShowExperimentalBookSourcesOption ?? false, allowTeamCollectionEnabled = dialog?.AllowTeamCollectionOptionEnabled ?? true, - aiSourceBubblesKnownTargetLanguages = GetAiSourceBubblesKnownTargetLanguages( - dialog - ), + aiSourceBubblesValidation, }; } - private List GetAiSourceBubblesKnownTargetLanguages(CollectionSettingsDialog dialog) + private object GetAiSourceBubblesValidationState(CollectionSettingsDialog dialog) { - var pendingOrCurrentWritingSystems = new[] + var currentSettings = GetAiSourceBubblesSettings(dialog); + var currentFingerprint = AiSourceBubblesService.GetConfigurationFingerprint( + currentSettings + ); + var validatedFingerprint = + dialog?.PendingAiSourceBubblesValidatedConfigurationFingerprint + ?? _collectionSettings.AiSourceBubblesValidatedConfigurationFingerprint; + var succeeded = + dialog?.PendingAiSourceBubblesLastValidationSucceeded + ?? _collectionSettings.AiSourceBubblesLastValidationSucceeded; + var message = + dialog?.PendingAiSourceBubblesLastValidationMessage + ?? _collectionSettings.AiSourceBubblesLastValidationMessage; + var isCurrent = String.Equals( + currentFingerprint, + validatedFingerprint, + StringComparison.Ordinal + ); + + return new { - dialog?.PendingLanguage1 ?? _collectionSettings.Language1, - dialog?.PendingLanguage2 ?? _collectionSettings.Language2, - dialog?.PendingLanguage3 ?? _collectionSettings.Language3, + currentFingerprint, + validatedFingerprint = isCurrent ? validatedFingerprint : String.Empty, + succeeded = isCurrent && succeeded, + message = isCurrent ? message : String.Empty, }; + } - var knownLanguages = new List(); - var seenLanguageTags = new HashSet(StringComparer.OrdinalIgnoreCase); + private CollectionSettings GetAiSourceBubblesSettings(CollectionSettingsDialog dialog) + { + return new CollectionSettings + { + Subscription = _collectionSettings.Subscription, + AiSourceBubblesProviderId = + dialog?.PendingAiSourceBubblesProviderId + ?? _collectionSettings.AiSourceBubblesProviderId, + AiSourceBubblesTargetLanguageTag = + dialog?.PendingAiSourceBubblesTargetLanguageTag + ?? _collectionSettings.AiSourceBubblesTargetLanguageTag, + AiSourceBubblesDeepLApiKey = + dialog?.PendingAiSourceBubblesDeepLApiKey + ?? _collectionSettings.AiSourceBubblesDeepLApiKey, + AiSourceBubblesGoogleServiceAccountEmail = + dialog?.PendingAiSourceBubblesGoogleServiceAccountEmail + ?? _collectionSettings.AiSourceBubblesGoogleServiceAccountEmail, + AiSourceBubblesGooglePrivateKey = + dialog?.PendingAiSourceBubblesGooglePrivateKey + ?? _collectionSettings.AiSourceBubblesGooglePrivateKey, + }; + } - for (var i = 0; i < pendingOrCurrentWritingSystems.Length; i++) + private static void InvalidateAiSourceBubblesValidation(CollectionSettingsDialog dialog) + { + var pendingSettings = new CollectionSettings { - var writingSystem = pendingOrCurrentWritingSystems[i]; - if (writingSystem == null || string.IsNullOrWhiteSpace(writingSystem.Tag)) - { - continue; - } + AiSourceBubblesProviderId = dialog.PendingAiSourceBubblesProviderId, + AiSourceBubblesTargetLanguageTag = dialog.PendingAiSourceBubblesTargetLanguageTag, + AiSourceBubblesDeepLApiKey = dialog.PendingAiSourceBubblesDeepLApiKey, + AiSourceBubblesGoogleServiceAccountEmail = + dialog.PendingAiSourceBubblesGoogleServiceAccountEmail, + AiSourceBubblesGooglePrivateKey = dialog.PendingAiSourceBubblesGooglePrivateKey, + }; + var currentFingerprint = AiSourceBubblesService.GetConfigurationFingerprint( + pendingSettings + ); + if ( + String.Equals( + dialog.PendingAiSourceBubblesValidatedConfigurationFingerprint, + currentFingerprint, + StringComparison.Ordinal + ) + ) + { + return; + } - if (!seenLanguageTags.Add(writingSystem.Tag)) - { - continue; - } + dialog.PendingAiSourceBubblesValidatedConfigurationFingerprint = String.Empty; + dialog.PendingAiSourceBubblesLastValidationSucceeded = false; + dialog.PendingAiSourceBubblesLastValidationMessage = String.Empty; + } - var displayName = string.IsNullOrWhiteSpace(writingSystem.Name) - ? writingSystem.Tag - : writingSystem.Name; - knownLanguages.Add( - new - { - value = writingSystem.Tag, - label = $"L{i + 1}: {displayName} ({writingSystem.Tag})", - } - ); + /// + /// Validates the AI Source Bubbles configuration currently being edited in Collection Settings. + /// + private async Task HandleValidateAiSourceBubblesAsync(ApiRequest request) + { + if (request.HttpMethod != HttpMethods.Post) + { + request.Failed(HttpStatusCode.MethodNotAllowed, "Only POST is supported."); + return; } - return knownLanguages; + var dialog = DialogBeingEdited; + var requestJson = request.RequiredPostJson(); + if (dialog != null && !String.IsNullOrWhiteSpace(requestJson)) + { + StoreAdvancedSettingsData(JObject.Parse(requestJson), dialog); + } + + var settings = GetAiSourceBubblesSettings(dialog); + var validationResult = new AiSourceBubblesValidationResult + { + ConfigurationFingerprint = AiSourceBubblesService.GetConfigurationFingerprint( + settings + ), + Succeeded = false, + Message = String.Empty, + }; + + try + { + validationResult = await new AiSourceBubblesService( + settings + ).ValidateConfigurationAsync(); + } + catch (ArgumentException e) + { + validationResult.Message = e.Message; + } + catch (InvalidOperationException e) + { + validationResult.Message = e.Message; + } + catch (HttpRequestException e) + { + validationResult.Message = e.Message; + } + catch (CryptographicException e) + { + validationResult.Message = e.Message; + } + catch (JsonException e) + { + validationResult.Message = e.Message; + } + + if (dialog != null) + { + dialog.PendingAiSourceBubblesValidatedConfigurationFingerprint = + validationResult.ConfigurationFingerprint; + dialog.PendingAiSourceBubblesLastValidationSucceeded = validationResult.Succeeded; + dialog.PendingAiSourceBubblesLastValidationMessage = validationResult.Message; + } + + request.ReplyWithJson(validationResult); } - private void StoreAdvancedSettingsData(ApiRequest request, CollectionSettingsDialog dialog) + /// + /// Gets the provider-backed list of target languages for the AI Source Bubbles settings currently being edited. + /// + private async Task HandleGetAiSourceBubblesSupportedLanguagesAsync(ApiRequest request) { - var data = JObject.Parse(request.RequiredPostJson()); + if (request.HttpMethod != HttpMethods.Post) + { + request.Failed(HttpStatusCode.MethodNotAllowed, "Only POST is supported."); + return; + } + + var dialog = DialogBeingEdited; + var requestJson = request.RequiredPostJson(); + if (dialog != null && !String.IsNullOrWhiteSpace(requestJson)) + { + StoreAdvancedSettingsData(JObject.Parse(requestJson), dialog); + } + + var settings = GetAiSourceBubblesSettings(dialog); + try + { + var languages = await new AiSourceBubblesService( + settings + ).GetSupportedTargetLanguagesAsync(); + request.ReplyWithJson(new { languages, message = String.Empty }); + } + catch (ArgumentException e) + { + request.ReplyWithJson( + new { languages = Array.Empty(), message = e.Message } + ); + } + catch (InvalidOperationException e) + { + request.ReplyWithJson( + new { languages = Array.Empty(), message = e.Message } + ); + } + catch (HttpRequestException e) + { + request.ReplyWithJson( + new { languages = Array.Empty(), message = e.Message } + ); + } + catch (CryptographicException e) + { + request.ReplyWithJson( + new { languages = Array.Empty(), message = e.Message } + ); + } + catch (JsonException e) + { + request.ReplyWithJson( + new { languages = Array.Empty(), message = e.Message } + ); + } + } + + private void StoreAdvancedSettingsData(JObject data, CollectionSettingsDialog dialog) + { + var aiSourceBubblesConfigurationChanged = false; var autoUpdateToken = data["autoUpdate"]; if (autoUpdateToken != null) @@ -430,40 +615,70 @@ private void StoreAdvancedSettingsData(ApiRequest request, CollectionSettingsDia var aiSourceBubblesProviderToken = data["aiSourceBubblesProvider"]; if (aiSourceBubblesProviderToken != null) { - dialog.PendingAiSourceBubblesProviderId = - AiSourceBubblesService.NormalizeProviderId( - aiSourceBubblesProviderToken.Value() - ); + var providerId = AiSourceBubblesService.NormalizeProviderId( + aiSourceBubblesProviderToken.Value() + ); + aiSourceBubblesConfigurationChanged |= !String.Equals( + dialog.PendingAiSourceBubblesProviderId, + providerId, + StringComparison.OrdinalIgnoreCase + ); + dialog.PendingAiSourceBubblesProviderId = providerId; } var aiSourceBubblesTargetLanguageTagToken = data["aiSourceBubblesTargetLanguageTag"]; if (aiSourceBubblesTargetLanguageTagToken != null) { - dialog.PendingAiSourceBubblesTargetLanguageTag = - aiSourceBubblesTargetLanguageTagToken.Value(); + var targetLanguageTag = aiSourceBubblesTargetLanguageTagToken.Value(); + aiSourceBubblesConfigurationChanged |= !String.Equals( + dialog.PendingAiSourceBubblesTargetLanguageTag, + targetLanguageTag, + StringComparison.Ordinal + ); + dialog.PendingAiSourceBubblesTargetLanguageTag = targetLanguageTag; } var aiSourceBubblesDeepLApiKeyToken = data["aiSourceBubblesDeepLApiKey"]; if (aiSourceBubblesDeepLApiKeyToken != null) { - dialog.PendingAiSourceBubblesDeepLApiKey = - aiSourceBubblesDeepLApiKeyToken.Value(); + var deepLApiKey = aiSourceBubblesDeepLApiKeyToken.Value(); + aiSourceBubblesConfigurationChanged |= !String.Equals( + dialog.PendingAiSourceBubblesDeepLApiKey, + deepLApiKey, + StringComparison.Ordinal + ); + dialog.PendingAiSourceBubblesDeepLApiKey = deepLApiKey; } - var aiSourceBubblesGoogleServiceAccountEmailToken = data[ "aiSourceBubblesGoogleServiceAccountEmail" ]; if (aiSourceBubblesGoogleServiceAccountEmailToken != null) { - dialog.PendingAiSourceBubblesGoogleServiceAccountEmail = + var googleServiceAccountEmail = aiSourceBubblesGoogleServiceAccountEmailToken.Value(); + aiSourceBubblesConfigurationChanged |= !String.Equals( + dialog.PendingAiSourceBubblesGoogleServiceAccountEmail, + googleServiceAccountEmail, + StringComparison.Ordinal + ); + dialog.PendingAiSourceBubblesGoogleServiceAccountEmail = googleServiceAccountEmail; } var aiSourceBubblesGooglePrivateKeyToken = data["aiSourceBubblesGooglePrivateKey"]; if (aiSourceBubblesGooglePrivateKeyToken != null) { - dialog.PendingAiSourceBubblesGooglePrivateKey = - aiSourceBubblesGooglePrivateKeyToken.Value(); + var googlePrivateKey = aiSourceBubblesGooglePrivateKeyToken.Value(); + aiSourceBubblesConfigurationChanged |= !String.Equals( + dialog.PendingAiSourceBubblesGooglePrivateKey, + googlePrivateKey, + StringComparison.Ordinal + ); + dialog.PendingAiSourceBubblesGooglePrivateKey = googlePrivateKey; + } + + if (aiSourceBubblesConfigurationChanged) + { + InvalidateAiSourceBubblesValidation(dialog); } var showQrCodeToken = data["showQrCode"]; diff --git a/src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs b/src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs index aea969466c67..fe91394299d4 100644 --- a/src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs +++ b/src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs @@ -1,5 +1,10 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using Bloom; using Bloom.AiSourceBubbles; @@ -50,6 +55,61 @@ public void GetAiLanguageTag_AppendsNormalizedProviderSuffix() ); } + [Test] + public void GetGoogleProjectIdFromServiceAccountEmail_ParsesProjectId() + { + Assert.That( + AiSourceBubblesService.GetGoogleProjectIdFromServiceAccountEmail( + "translator@test-project-123.iam.gserviceaccount.com" + ), + Is.EqualTo("test-project-123") + ); + } + + [Test] + public void GetConfigurationFingerprint_ChangesWhenRelevantSettingsChange() + { + var settings = MakeCollectionSettings("deepl"); + settings.AiSourceBubblesDeepLApiKey = "first-key"; + + var originalFingerprint = AiSourceBubblesService.GetConfigurationFingerprint(settings); + + settings.AiSourceBubblesDeepLApiKey = "second-key"; + + Assert.That( + AiSourceBubblesService.GetConfigurationFingerprint(settings), + Is.Not.EqualTo(originalFingerprint) + ); + } + + [Test] + public void GetConfigurationFingerprint_IgnoresUnusedProviderCredentials() + { + var settings = MakeCollectionSettings("deepl"); + settings.AiSourceBubblesDeepLApiKey = "deepl-key"; + settings.AiSourceBubblesGoogleServiceAccountEmail = "first@example.com"; + settings.AiSourceBubblesGooglePrivateKey = "first-private-key"; + + var originalFingerprint = AiSourceBubblesService.GetConfigurationFingerprint(settings); + + settings.AiSourceBubblesGoogleServiceAccountEmail = "second@example.com"; + settings.AiSourceBubblesGooglePrivateKey = "second-private-key"; + + Assert.That( + AiSourceBubblesService.GetConfigurationFingerprint(settings), + Is.EqualTo(originalFingerprint) + ); + } + + [Test] + public void NormalizeProviderId_Alpha2Alias_ReturnsEmpty() + { + Assert.That( + AiSourceBubblesService.NormalizeProviderId("alpha2"), + Is.EqualTo(string.Empty) + ); + } + [Test] public void TranslateAsync_WithoutTargetLanguageTag_ThrowsHelpfulError() { @@ -70,6 +130,105 @@ await service.TranslateAsync( Assert.That(exception.Message, Does.Contain("target language tag")); } + [Test] + public async Task TranslateAsync_WritesRequestAndResponseToConsole() + { + var collectionSettings = MakeCollectionSettings("deepl"); + var fakeProvider = new FakeAiSourceBubblesTranslationProvider( + "deepl", + "Bonjour le monde." + ); + var service = new AiSourceBubblesService( + collectionSettings, + new Dictionary + { + { "deepl", fakeProvider }, + } + ); + var originalConsoleOut = Console.Out; + using (var output = new StringWriter()) + { + Console.SetOut(output); + + try + { + var result = await service.TranslateAsync( + new AiSourceBubblesTranslateRequest + { + SourceText = "Hello world.", + SourceLanguageTag = "en", + } + ); + + Assert.That(result.Text, Is.EqualTo("Bonjour le monde.")); + } + finally + { + Console.SetOut(originalConsoleOut); + } + + var log = output.ToString(); + Assert.That(log, Does.Contain("[AiSourceBubbles][request]")); + Assert.That(log, Does.Contain("[AiSourceBubbles][response]")); + Assert.That(log, Does.Contain("provider=deepl")); + Assert.That(log, Does.Contain("sourceLanguage=en")); + Assert.That(log, Does.Contain("targetLanguage=fr")); + Assert.That(log, Does.Contain("input=\"Hello world.\"")); + Assert.That(log, Does.Contain("output=\"Bonjour le monde.\"")); + Assert.That(log, Does.Contain("elapsedMs=")); + Assert.That(log, Does.Contain("time=")); + } + } + + [Test] + public void TranslateAsync_WhenProviderThrows_WritesFailureToConsole() + { + var collectionSettings = MakeCollectionSettings("deepl"); + var fakeProvider = new FakeAiSourceBubblesTranslationProvider( + "deepl", + exceptionToThrow: new InvalidOperationException("boom") + ); + var service = new AiSourceBubblesService( + collectionSettings, + new Dictionary + { + { "deepl", fakeProvider }, + } + ); + var originalConsoleOut = Console.Out; + using (var output = new StringWriter()) + { + Console.SetOut(output); + + try + { + var exception = Assert.ThrowsAsync(async () => + await service.TranslateAsync( + new AiSourceBubblesTranslateRequest + { + SourceText = "Hello world.", + SourceLanguageTag = "en", + } + ) + ); + + Assert.That(exception.Message, Is.EqualTo("boom")); + } + finally + { + Console.SetOut(originalConsoleOut); + } + + var log = output.ToString(); + Assert.That(log, Does.Contain("[AiSourceBubbles][request]")); + Assert.That(log, Does.Contain("[AiSourceBubbles][response]")); + Assert.That(log, Does.Contain("error=\"boom\"")); + Assert.That(log, Does.Contain("input=\"Hello world.\"")); + Assert.That(log, Does.Contain("elapsedMs=")); + Assert.That(log, Does.Contain("time=")); + } + } + private static CollectionSettings MakeCollectionSettings(string providerId) { var collectionSettings = new CollectionSettings @@ -80,6 +239,50 @@ private static CollectionSettings MakeCollectionSettings(string providerId) }; return collectionSettings; } + + private sealed class FakeAiSourceBubblesTranslationProvider + : IAiSourceBubblesTranslationProvider + { + private readonly string _translatedText; + private readonly Exception _exceptionToThrow; + + public FakeAiSourceBubblesTranslationProvider( + string providerId, + string translatedText = null, + Exception exceptionToThrow = null + ) + { + ProviderId = providerId; + _translatedText = translatedText; + _exceptionToThrow = exceptionToThrow; + } + + public string ProviderId { get; } + + public Task> GetSupportedTargetLanguagesAsync( + CollectionSettings collectionSettings, + HttpClient httpClient + ) + { + return Task.FromResult(new List()); + } + + public Task TranslateAsync( + CollectionSettings collectionSettings, + string sourceText, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient + ) + { + if (_exceptionToThrow != null) + { + throw _exceptionToThrow; + } + + return Task.FromResult(_translatedText); + } + } } public abstract class AiSourceBubblesLiveTranslationTestsBase diff --git a/src/BloomTests/Book/RuntimeInformationInjectorTests.cs b/src/BloomTests/Book/RuntimeInformationInjectorTests.cs index b1154bf61431..f0289119ecc6 100644 --- a/src/BloomTests/Book/RuntimeInformationInjectorTests.cs +++ b/src/BloomTests/Book/RuntimeInformationInjectorTests.cs @@ -3,8 +3,12 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Bloom; +using Bloom.AiSourceBubbles; using Bloom.Book; using Bloom.Collection; +using Bloom.SubscriptionAndFeatures; +using Bloom.web.controllers; using NUnit.Framework; namespace BloomTests.Book @@ -12,6 +16,25 @@ namespace BloomTests.Book public class RuntimeInformationInjectorTests { private HtmlDom _bookDom; + private bool _previousAiSourceBubblesEnabled; + + [SetUp] + public void Setup() + { + _previousAiSourceBubblesEnabled = ExperimentalFeatures.IsFeatureEnabled( + ExperimentalFeatures.kAiSourceBubbles + ); + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, true); + } + + [TearDown] + public void TearDown() + { + ExperimentalFeatures.SetValue( + ExperimentalFeatures.kAiSourceBubbles, + _previousAiSourceBubblesEnabled + ); + } private void SetDom(string bodyContents) { @@ -83,5 +106,51 @@ public void AddLanguagesUsedInPage_InvalidLanguageSubTag_DoesNotThrow(string inv settings ); } + + [Test] + public void AddUISettingsToDom_AiSourceBubblesDisabledWhenValidationFingerprintMissing() + { + SetDom("
    "); + var collectionSettings = MakeAiSourceBubblesCollectionSettings(); + collectionSettings.AiSourceBubblesLastValidationSucceeded = true; + collectionSettings.AiSourceBubblesValidatedConfigurationFingerprint = "stale"; + var bookData = new BookData(_bookDom, collectionSettings, null); + + RuntimeInformationInjector.AddUISettingsToDom(_bookDom, bookData, null); + + var scriptContents = _bookDom + .RawDom.SelectSingleNode("//script[@id='ui-settings']") + .InnerText; + Assert.That(scriptContents, Does.Contain("\"allowAiSourceBubbles\":false")); + } + + [Test] + public void AddUISettingsToDom_AiSourceBubblesEnabledWhenValidationMatchesCurrentConfiguration() + { + SetDom("
    "); + var collectionSettings = MakeAiSourceBubblesCollectionSettings(); + collectionSettings.AiSourceBubblesLastValidationSucceeded = true; + collectionSettings.AiSourceBubblesValidatedConfigurationFingerprint = + AiSourceBubblesService.GetConfigurationFingerprint(collectionSettings); + var bookData = new BookData(_bookDom, collectionSettings, null); + + RuntimeInformationInjector.AddUISettingsToDom(_bookDom, bookData, null); + + var scriptContents = _bookDom + .RawDom.SelectSingleNode("//script[@id='ui-settings']") + .InnerText; + Assert.That(scriptContents, Does.Contain("\"allowAiSourceBubbles\":true")); + } + + private static CollectionSettings MakeAiSourceBubblesCollectionSettings() + { + return new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), + AiSourceBubblesProviderId = "deepl", + AiSourceBubblesTargetLanguageTag = "fr", + AiSourceBubblesDeepLApiKey = "sample-key", + }; + } } } From 0b0bb3d7c1573ea998bab06d6ff9adbbe1be3d61 Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 14 Jul 2026 17:40:40 -0600 Subject: [PATCH 05/10] AI Source Bubbles: improvements and fixes (BL-16549) - Rename AiSourceBubbles -> AiTranslation across C# and TypeScript. - Alpha2: treat "pending" translation_status as in-progress rather than a hard failure (it was aborting otherwise-successful translations). - Translation progress dialog: always wait on completion, showing an OK button; Cancel is only present while work is in progress. - Add a "Remove AI Source Translations" book menu command (gated by save permission) that strips all AI source translations and confirms via a Bloom message box. - Resilient supported-languages aggregation: one engine failing to list its languages (e.g. a DeepL key lacking the languages:read scope) no longer blanks the whole union or blocks translation; the error, when every engine fails, is shown red and wraps instead of scrolling the dialog. - Fix: Google credentials were written onto every engine's wire record, wiping the other engines' validation when Google creds were edited. Co-Authored-By: Claude Opus 4.8 (1M context) --- DistFiles/localization/en/Bloom.xlf | 57 +- .../localization/en/BloomLowPriority.xlf | 10 + PAPERCUTS.md | 12 + .../bookEdit/js/collectionSettings.d.ts | 3 - .../sourceBubbles/BloomSourceBubbles.tsx | 502 +--------- .../sourceBubbles/SourceBubblesSpec.ts | 240 +++-- .../sourceBubbles/aiTranslationDisplay.ts | 53 ++ .../collection/AdvancedSettingsPanel.test.tsx | 523 ++++++++--- .../collection/AdvancedSettingsPanel.tsx | 149 ++- .../AiSourceBubblesSettingsGroup.tsx | 518 ----------- .../collection/AiTranslationSettingsGroup.tsx | 868 ++++++++++++++++++ .../collectionsTab/BookButton.tsx | 9 + .../Progress/ProgressDialog.tsx | 45 +- .../AiSourceBubbles/AiSourceBubblesService.cs | 851 ----------------- .../AiTranslation/AiTranslationBookScanner.cs | 500 ++++++++++ .../AiTranslation/AiTranslationBookUpdater.cs | 405 ++++++++ .../AiTranslationEngineSettings.cs | 69 ++ .../AiTranslation/AiTranslationService.cs | 501 ++++++++++ .../Alpha2TranslationProvider.cs | 463 ++++++++++ .../AiTranslation/DeepLTranslationProvider.cs | 151 +++ .../GoogleTranslationProvider.cs | 248 +++++ .../AiTranslation/IAiTranslationProvider.cs | 65 ++ src/BloomExe/Book/Book.cs | 72 -- .../Book/RuntimeInformationInjector.cs | 33 +- src/BloomExe/Collection/CollectionSettings.cs | 163 ++-- .../Collection/CollectionSettingsDialog.cs | 55 +- src/BloomExe/Edit/EditingModel.cs | 17 +- src/BloomExe/ProjectContext.cs | 7 +- .../web/controllers/AiSourceBubblesApi.cs | 63 -- .../web/controllers/BookCommandsApi.cs | 43 + .../web/controllers/CollectionSettingsApi.cs | 372 ++++---- .../AiSourceBubblesServiceTests.cs | 396 -------- .../AiTranslationBookScannerTests.cs | 560 +++++++++++ .../AiTranslationBookUpdaterTests.cs | 427 +++++++++ .../AiTranslationServiceTests.cs | 602 ++++++++++++ .../Alpha2TranslationProviderTests.cs | 139 +++ .../Book/RuntimeInformationInjectorTests.cs | 51 +- 37 files changed, 6185 insertions(+), 3057 deletions(-) create mode 100644 PAPERCUTS.md create mode 100644 src/BloomBrowserUI/bookEdit/sourceBubbles/aiTranslationDisplay.ts delete mode 100644 src/BloomBrowserUI/collection/AiSourceBubblesSettingsGroup.tsx create mode 100644 src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx delete mode 100644 src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs create mode 100644 src/BloomExe/AiTranslation/AiTranslationBookScanner.cs create mode 100644 src/BloomExe/AiTranslation/AiTranslationBookUpdater.cs create mode 100644 src/BloomExe/AiTranslation/AiTranslationEngineSettings.cs create mode 100644 src/BloomExe/AiTranslation/AiTranslationService.cs create mode 100644 src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs create mode 100644 src/BloomExe/AiTranslation/DeepLTranslationProvider.cs create mode 100644 src/BloomExe/AiTranslation/GoogleTranslationProvider.cs create mode 100644 src/BloomExe/AiTranslation/IAiTranslationProvider.cs delete mode 100644 src/BloomExe/web/controllers/AiSourceBubblesApi.cs delete mode 100644 src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs create mode 100644 src/BloomTests/AiTranslation/AiTranslationBookScannerTests.cs create mode 100644 src/BloomTests/AiTranslation/AiTranslationBookUpdaterTests.cs create mode 100644 src/BloomTests/AiTranslation/AiTranslationServiceTests.cs create mode 100644 src/BloomTests/AiTranslation/Alpha2TranslationProviderTests.cs diff --git a/DistFiles/localization/en/Bloom.xlf b/DistFiles/localization/en/Bloom.xlf index 5faa5beb247d..a26202fecd25 100644 --- a/DistFiles/localization/en/Bloom.xlf +++ b/DistFiles/localization/en/Bloom.xlf @@ -387,10 +387,6 @@ AI Source Bubbles ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.SectionLabel - - Provider - ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.ProviderLabel - Target Language ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TargetLanguageLabel @@ -398,18 +394,26 @@ Choose one of this collection's languages, or Other... to select another language. ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TargetLanguageDescription + Currently unused (orphaned from an earlier design); left in place rather than deleted. Other... ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.OtherLanguageLabel + Currently unused (orphaned from an earlier design); left in place rather than deleted. + + + DeepL + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLEnabledLabel + Label of the checkbox that enables the DeepL translation engine for AI source bubbles. "DeepL" is a product name and must not be translated. DeepL API key ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLApiKeyLabel - - Alpha2 API key - ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2ApiKeyLabel + + Google Translate + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GoogleEnabledLabel + Label of the checkbox that enables the Google Translate engine for AI source bubbles. "Google Translate" is a product name and must not be translated. Google service account email @@ -419,6 +423,20 @@ Google private key ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GooglePrivateKeyLabel + + SIL Alpha2 + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2EnabledLabel + Label of the checkbox that enables the SIL Alpha2 translation engine for AI source bubbles. "SIL Alpha2" is a product name and must not be translated. + + + Alpha2 API key + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2ApiKeyLabel + + + Translation Test + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TranslationTestLabel + Label of the row showing a live translation-test result for one AI source bubbles engine (DeepL, Google Translate, or SIL Alpha2); this same label is reused once per engine. + Bloom is switching the default font for "{0}" to the new "Andika New Basic". ID: CollectionSettingsDialog.AndikaNewBasicUpdate1 @@ -1384,6 +1402,31 @@ Add Page... ID: EditTab.AddPageDialog.Title + + Cancelled. + ID: EditTab.AiTranslation.Cancelled + Status line in the AI translation progress dialog after the user clicks Cancel; the book is left unchanged. + + + Translating + ID: EditTab.AiTranslation.DialogTitle + Title of the progress dialog shown while AI engines translate the book's text before it opens for editing. + + + {0}: done. + ID: EditTab.AiTranslation.EngineDone + Progress line in the AI translation dialog. {0} is replaced with the translation engine's name (e.g. "DeepL"), which must not be translated. + + + {0}: {1} + ID: EditTab.AiTranslation.EngineError + Error line in the AI translation dialog. {0} is the translation engine's name (e.g. "DeepL"); {1} is an error message. Only the separator punctuation can be localized. + + + {0}: translating {1} text box(es)... + ID: EditTab.AiTranslation.EngineTranslating + Progress line in the AI translation dialog. {0} is the translation engine's name (e.g. "DeepL"), which must not be translated; {1} is the number of text boxes being translated. + If you need somewhere to put more information about the book, you can use this page, which is the inside of the back cover. ID: EditTab.BackMatter.InsideBackCoverTextPrompt diff --git a/DistFiles/localization/en/BloomLowPriority.xlf b/DistFiles/localization/en/BloomLowPriority.xlf index 41d3d319086f..d5472c84be87 100644 --- a/DistFiles/localization/en/BloomLowPriority.xlf +++ b/DistFiles/localization/en/BloomLowPriority.xlf @@ -353,6 +353,16 @@ ID: CollectionTab.BookMenu.MoveToCurrentCollection {0} will be replaced with the name of the collection that the user currently has open for editing. + + Remove AI Source Translations + ID: CollectionTab.BookMenu.RemoveAiSourceTranslations + A book right-click menu command in the Collection tab. It deletes the AI-generated translations that appear as source bubbles when editing. "AI" stands for artificial intelligence. + + + Bloom removed the AI source translations from this book. + ID: CollectionTab.BookMenu.RemovedAiSourceTranslations + Confirmation message shown after the "Remove AI Source Translations" menu command finishes. "Bloom" is the product name and must not be translated; "AI" stands for artificial intelligence. + diff --git a/PAPERCUTS.md b/PAPERCUTS.md new file mode 100644 index 000000000000..51a168af2c8d --- /dev/null +++ b/PAPERCUTS.md @@ -0,0 +1,12 @@ +# Papercuts + +Small dev/agent/tooling friction points captured mid-task (see the `papercut` skill). + +- Running `dotnet test` (or any BloomExe build) while a `./go.sh` / `dotnet watch` + Bloom is live fails at the copy-to-output step: the running process locks both + `output/Debug/AnyCPU/Bloom.exe` (native apphost) and, once hot-reload deltas have + been applied, `Bloom.dll` too (MSB3026/MSB3027 "being used by another process"). + Compilation itself succeeds; only the copy fails, so the tests never run. + Workaround that neither kills the running instance nor touches the locked output: + redirect the whole build to a scratch dir and skip the apphost, e.g. + `dotnet test src/BloomTests/BloomTests.csproj --filter ... -p:UseAppHost=false -p:OutDir=`. diff --git a/src/BloomBrowserUI/bookEdit/js/collectionSettings.d.ts b/src/BloomBrowserUI/bookEdit/js/collectionSettings.d.ts index fd4cea87932f..c0f021820259 100644 --- a/src/BloomBrowserUI/bookEdit/js/collectionSettings.d.ts +++ b/src/BloomBrowserUI/bookEdit/js/collectionSettings.d.ts @@ -10,9 +10,6 @@ interface ICollectionSettings { currentCollectionLanguage2: string; currentCollectionLanguage3: string; allowAiSourceBubbles: boolean; - aiSourceBubblesProvider: string; - aiSourceBubblesTargetLanguageTag: string; - aiSourceBubblesLanguageTag: string; browserRoot: string; topics: string[]; } diff --git a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx index 953f442bb51c..8c8aecc0e78b 100644 --- a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx +++ b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx @@ -18,23 +18,18 @@ import StyleEditor from "../StyleEditor/StyleEditor"; import bloomQtipUtils from "../js/bloomQtipUtils"; import "../../lib/jquery.easytabs.js"; //load into global space import BloomHintBubbles from "../js/BloomHintBubbles"; -import { getEditablePageBundleExports } from "../js/workspaceFrames"; -import { postJson, postJsonAsync, postString } from "../../utils/bloomApi"; +import { postJson, postString } from "../../utils/bloomApi"; import CopyContentButton from "../../react_components/CopyContentButton"; +import { + getAiProviderDisplayName, + getLanguageNameFromBrowser, + isAiLanguageTag, + removeAiTranslationDivsFromClone, +} from "./aiTranslationDisplay"; declare function GetSettings(): ICollectionSettings; export default class BloomSourceBubbles { - private static readonly kAiSourceBubbleFingerprintAttr = - "data-ai-source-bubble-fingerprint"; - private static readonly kAiSourceBubblePendingFingerprintAttr = - "data-ai-source-bubble-pending-fingerprint"; - private static readonly kAiSourceBubbleRequestTokenAttr = - "data-ai-source-bubble-request-token"; - private static readonly kAiSourceBubbleClass = - "bloom-ai-source-bubble-translation"; - private static nextAiSourceBubbleRequestToken = 0; - //:empty is not quite enough... we don't want to show bubbles if all there is is an empty paragraph private static hasNoText(obj: HTMLElement): boolean { //if(typeof (obj) == 'HTMLTextAreaElement') { @@ -43,449 +38,10 @@ export default class BloomSourceBubbles { return $.trim($(obj).text()).length === 0; } - private static isAiLanguageTag(languageTag: string | undefined): boolean { - return !!languageTag && languageTag.includes("-x-ai"); - } - - private static getPreferredSourceDiv(divForBubble: JQuery): JQuery { - const settings = GetSettings(); - const preferredLanguages = [ - settings.defaultSourceLanguage, - settings.defaultSourceLanguage2, - settings.currentCollectionLanguage2, - settings.currentCollectionLanguage3, - "en", - ].filter( - (languageTag, index, tags) => - !!languageTag && - !BloomSourceBubbles.isAiLanguageTag(languageTag) && - tags.indexOf(languageTag) === index, - ); - - for (const languageTag of preferredLanguages) { - const matchingDiv = divForBubble - .find(`div[lang='${languageTag}']`) - .filter( - (index, element) => - !BloomSourceBubbles.hasNoText(element as HTMLElement), - ) - .first(); - if (matchingDiv.length > 0) { - return matchingDiv; - } - } - - return divForBubble - .find("div[lang]") - .filter((index, element) => { - const languageTag = element.getAttribute("lang") || ""; - return ( - !BloomSourceBubbles.isAiLanguageTag(languageTag) && - !BloomSourceBubbles.hasNoText(element as HTMLElement) - ); - }) - .first(); - } - - private static getAiSourceBubbleLangTag(): string | undefined { - const settings = GetSettings(); - if ( - !settings.allowAiSourceBubbles || - !settings.aiSourceBubblesLanguageTag - ) { - return undefined; - } - return settings.aiSourceBubblesLanguageTag; - } - - private static getAiSourceBubbleFingerprint( - sourceText: string, - sourceLanguageTag: string, - aiLanguageTag: string, - ): string { - return [ - sourceLanguageTag, - aiLanguageTag, - sourceText.length.toString(), - BloomSourceBubbles.hashText(sourceText), - ].join("|"); - } - - private static hashText(text: string): string { - let hash = 0; - for (let i = 0; i < text.length; i++) { - hash = (hash * 31 + text.charCodeAt(i)) >>> 0; - } - - return hash.toString(36); - } - - private static removeAiSourceBubbleDivs( - group: JQuery, - currentLangTag?: string, - ): void { - group.find("div[lang]").each((index, element) => { - const langTag = element.getAttribute("lang") || ""; - if (!langTag.includes("-x-ai")) { - return; - } - - if (currentLangTag && langTag === currentLangTag) { - return; - } - - element.remove(); - }); - } - - private static syncVisibleAiSourceBubble( - group: HTMLElement, - sourceAiDiv: HTMLDivElement, - ): void { - const qtipId = group.getAttribute("aria-describedby"); - if (!qtipId) { - return; - } - - const tooltip = group.ownerDocument.querySelector(`#${qtipId}`); - if (!tooltip) { - return; - } - - const tooltipAiDivs = tooltip.querySelectorAll( - `.${BloomSourceBubbles.kAiSourceBubbleClass}`, - ); - tooltipAiDivs.forEach((tooltipAiDiv) => { - tooltipAiDiv.setAttribute( - "lang", - sourceAiDiv.getAttribute("lang") || "", - ); - - const fingerprint = sourceAiDiv.getAttribute( - BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, - ); - if (fingerprint) { - tooltipAiDiv.setAttribute( - BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, - fingerprint, - ); - } else { - tooltipAiDiv.removeAttribute( - BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, - ); - } - - const pendingFingerprint = sourceAiDiv.getAttribute( - BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, - ); - if (pendingFingerprint) { - tooltipAiDiv.setAttribute( - BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, - pendingFingerprint, - ); - } else { - tooltipAiDiv.removeAttribute( - BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, - ); - } - - const requestToken = sourceAiDiv.getAttribute( - BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, - ); - if (requestToken) { - tooltipAiDiv.setAttribute( - BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, - requestToken, - ); - } else { - tooltipAiDiv.removeAttribute( - BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, - ); - } - - tooltipAiDiv.innerText = sourceAiDiv.innerText; - }); - } - - private static refreshVisibleAiSourceBubble(group: HTMLElement): void { - const qtipId = group.getAttribute("aria-describedby"); - if (!qtipId) { - return; - } - - const tooltip = group.ownerDocument.querySelector( - `#${qtipId}`, - ); - if (!tooltip || tooltip.getAttribute("aria-hidden") === "true") { - return; - } - - const selectedTab = tooltip.querySelector( - "a.sourceTextTab.active", - ); - const selectedLangTag = selectedTab?.getAttribute("href")?.substring(1); - const refreshedBubble = BloomSourceBubbles.MakeSourceTextDivForGroup( - group, - selectedLangTag, - ); - if (refreshedBubble.length === 0) { - return; - } - - BloomSourceBubbles.removeSourceBubbles(group); - BloomSourceBubbles.MakeSourceBubblesIntoQtips( - group, - refreshedBubble, - selectedLangTag, - ); - } - private static maybeRememberSourceBubbleLanguage(langTag: string): void { postString("editView/sourceTextTab", langTag); } - private static getExistingAiSourceBubbleDiv( - group: JQuery, - aiLanguageTag: string, - ): HTMLDivElement | undefined { - return group.find(`div[lang='${aiLanguageTag}']`).first().get(0) as - | HTMLDivElement - | undefined; - } - - private static ensureAiSourceBubbleDiv( - group: JQuery, - aiLanguageTag: string, - ): HTMLDivElement { - const existingDiv = BloomSourceBubbles.getExistingAiSourceBubbleDiv( - group, - aiLanguageTag, - ); - if (existingDiv) { - existingDiv.classList.add( - "bloom-editable", - BloomSourceBubbles.kAiSourceBubbleClass, - ); - existingDiv.setAttribute("lang", aiLanguageTag); - existingDiv.setAttribute("contenteditable", "true"); - return existingDiv; - } - - const aiDiv = document.createElement("div"); - aiDiv.className = `bloom-editable ${BloomSourceBubbles.kAiSourceBubbleClass}`; - aiDiv.setAttribute("lang", aiLanguageTag); - aiDiv.setAttribute("contenteditable", "true"); - group.append(aiDiv); - return aiDiv; - } - - private static isCurrentAiSourceBubbleTranslation( - aiDiv: HTMLDivElement, - fingerprint: string, - ): boolean { - return ( - aiDiv.getAttribute( - BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, - ) === fingerprint && !BloomSourceBubbles.hasNoText(aiDiv) - ); - } - - private static async wrapAiSourceBubbleUpdateWithPageContentDelay( - fn: () => Promise, - delayId: string, - ): Promise { - const editablePageBundle = getEditablePageBundleExports(); - if (!editablePageBundle) { - return fn(); - } - - editablePageBundle.addRequestPageContentDelay(delayId); - try { - const result = await fn(); - editablePageBundle.removeRequestPageContentDelay(delayId); - return result; - } catch (error) { - editablePageBundle.removeRequestPageContentDelay(delayId); - throw error; - } - } - - private static ensureAiSourceBubbleTranslation( - group: HTMLElement, - sourceText: string, - sourceLanguageTag: string, - aiLanguageTag: string, - ): void { - const $group = $(group); - BloomSourceBubbles.removeAiSourceBubbleDivs($group, aiLanguageTag); - const aiDiv = BloomSourceBubbles.ensureAiSourceBubbleDiv( - $group, - aiLanguageTag, - ); - const fingerprint = BloomSourceBubbles.getAiSourceBubbleFingerprint( - sourceText, - sourceLanguageTag, - aiLanguageTag, - ); - - if ( - BloomSourceBubbles.isCurrentAiSourceBubbleTranslation( - aiDiv, - fingerprint, - ) - ) { - aiDiv.removeAttribute( - BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, - ); - aiDiv.removeAttribute( - BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, - ); - BloomSourceBubbles.syncVisibleAiSourceBubble(group, aiDiv); - return; - } - - if ( - aiDiv.getAttribute( - BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, - ) === fingerprint - ) { - BloomSourceBubbles.syncVisibleAiSourceBubble(group, aiDiv); - return; - } - - const requestToken = - (++BloomSourceBubbles.nextAiSourceBubbleRequestToken).toString(); - aiDiv.setAttribute("lang", aiLanguageTag); - aiDiv.setAttribute( - BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, - fingerprint, - ); - aiDiv.setAttribute( - BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, - requestToken, - ); - aiDiv.removeAttribute( - BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, - ); - aiDiv.innerText = "Translating..."; - BloomSourceBubbles.syncVisibleAiSourceBubble(group, aiDiv); - - const delayId = `ai-source-bubble:${requestToken}`; - void BloomSourceBubbles.wrapAiSourceBubbleUpdateWithPageContentDelay( - async () => { - try { - const response = - await BloomSourceBubbles.translateSourceBubbleAsync( - sourceText, - sourceLanguageTag, - ); - if ( - aiDiv.getAttribute( - BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, - ) !== requestToken - ) { - return; - } - - const responseLanguageTag = - response?.aiLanguageTag || aiLanguageTag; - aiDiv.setAttribute("lang", responseLanguageTag); - aiDiv.innerText = - response?.text || "No translation returned."; - aiDiv.setAttribute( - BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, - BloomSourceBubbles.getAiSourceBubbleFingerprint( - sourceText, - sourceLanguageTag, - responseLanguageTag, - ), - ); - BloomSourceBubbles.syncVisibleAiSourceBubble(group, aiDiv); - } catch (error) { - if ( - aiDiv.getAttribute( - BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, - ) !== requestToken - ) { - return; - } - - aiDiv.innerText = - BloomSourceBubbles.getTranslationErrorMessage(error); - aiDiv.removeAttribute( - BloomSourceBubbles.kAiSourceBubbleFingerprintAttr, - ); - BloomSourceBubbles.syncVisibleAiSourceBubble(group, aiDiv); - } finally { - if ( - aiDiv.getAttribute( - BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, - ) === requestToken - ) { - aiDiv.removeAttribute( - BloomSourceBubbles.kAiSourceBubblePendingFingerprintAttr, - ); - aiDiv.removeAttribute( - BloomSourceBubbles.kAiSourceBubbleRequestTokenAttr, - ); - BloomSourceBubbles.syncVisibleAiSourceBubble( - group, - aiDiv, - ); - BloomSourceBubbles.refreshVisibleAiSourceBubble(group); - } - } - }, - delayId, - ); - } - - private static async translateSourceBubbleAsync( - sourceText: string, - sourceLanguageTag: string, - ): Promise<{ text?: string; aiLanguageTag?: string } | undefined> { - const response = await postJsonAsync("aiSourceBubbles/translate", { - sourceText, - sourceLanguageTag, - }); - const data = response?.data as - | { - text?: string; - aiLanguageTag?: string; - Text?: string; - AiLanguageTag?: string; - } - | undefined; - if (!data) { - return undefined; - } - - return { - text: data.text || data.Text, - aiLanguageTag: data.aiLanguageTag || data.AiLanguageTag, - }; - } - - private static getTranslationErrorMessage(error: unknown): string { - if (typeof error === "string") { - return error; - } - - if (typeof error === "object" && error !== null) { - const errorInfo = error as { - response?: { data?: string }; - message?: string; - }; - return ( - errorInfo.response?.data || - errorInfo.message || - "Translation failed." - ); - } - - return "Translation failed."; - } - private static getLanguageDisplayName(langTag: string): string { const aiSplitMarker = "-x-ai-"; const aiSplitIndex = langTag.indexOf(aiSplitMarker); @@ -499,10 +55,18 @@ export default class BloomSourceBubbles { const providerId = langTag.substring( aiSplitIndex + aiSplitMarker.length, ); + // getLanguageName echoes the tag back when it doesn't know the language, so treat + // that as "unknown" and let the browser's Intl database have a try. + const bloomLanguageName = + theOneLocalizationManager.getLanguageName(targetLanguageTag); const targetLanguageName = - theOneLocalizationManager.getLanguageName(targetLanguageTag) || + (bloomLanguageName !== targetLanguageTag + ? bloomLanguageName + : undefined) || + getLanguageNameFromBrowser(targetLanguageTag) || targetLanguageTag; - return `AI ${targetLanguageName}`; + const providerDisplayName = getAiProviderDisplayName(providerId); + return `AI ${targetLanguageName} (${providerDisplayName})`; } private static appendSourceTabLabel( @@ -510,11 +74,16 @@ export default class BloomSourceBubbles { langTag: string, localizedLanguageName: string, ): void { - if (!BloomSourceBubbles.isAiLanguageTag(langTag)) { + if (!isAiLanguageTag(langTag)) { anchor.textContent = localizedLanguageName; return; } + // TODO (BL-16549 follow-up): migrate to React 18 createRoot. Kept on the synchronous + // ReactDOM.render here deliberately for now -- these tab-label anchors are rebuilt each + // time bubbles are constructed, and createRoot's async render / one-root-per-container + // rules need their own change; behavior is unchanged in the meantime. + // eslint-disable-next-line react/no-deprecated ReactDOM.render( <> @@ -590,24 +159,6 @@ export default class BloomSourceBubbles { ): JQuery { if (group.classList.contains("bloom-no-source-bubble")) return $(); const liveGroup = $(group); - const aiSourceBubbleLangTag = - BloomSourceBubbles.getAiSourceBubbleLangTag(); - const sourceDiv = BloomSourceBubbles.getPreferredSourceDiv(liveGroup); - const sourceLanguageTag = sourceDiv.attr("lang"); - if ( - aiSourceBubbleLangTag && - sourceDiv.length > 0 && - sourceLanguageTag - ) { - BloomSourceBubbles.ensureAiSourceBubbleTranslation( - group, - sourceDiv.text(), - sourceLanguageTag, - aiSourceBubbleLangTag, - ); - } else { - BloomSourceBubbles.removeAiSourceBubbleDivs(liveGroup); - } // Copy source texts out to their own div, where we can make a bubble with tabs out of them // We do this because if we made a bubble out of the div, that would suck up the vernacular editable area, too, @@ -619,10 +170,9 @@ export default class BloomSourceBubbles { divForBubble.find("label.bubble").each((index, element) => { $(element).remove(); }); - BloomSourceBubbles.removeAiSourceBubbleDivs( - divForBubble, - aiSourceBubbleLangTag, - ); + if (!GetSettings().allowAiSourceBubbles) { + removeAiTranslationDivsFromClone(divForBubble); + } //make the source texts in the bubble read-only and remove any user font size adjustments divForBubble.find("textarea, div").each(function (): boolean { diff --git a/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts b/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts index 172753052f66..0c325f5dd553 100644 --- a/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts +++ b/src/BloomBrowserUI/bookEdit/sourceBubbles/SourceBubblesSpec.ts @@ -9,15 +9,13 @@ vi.mock("../../utils/bloomApi", async (importOriginal) => { return { ...actual, postJson: vi.fn(), - postJsonAsync: vi.fn(), postString: vi.fn(), }; }); -import { postJsonAsync, postString } from "../../utils/bloomApi"; +import { postString } from "../../utils/bloomApi"; import BloomSourceBubbles from "./BloomSourceBubbles"; -const mockedPostJsonAsync = vi.mocked(postJsonAsync); const mockedPostString = vi.mocked(postString); describe("SourceBubbles", () => { @@ -26,8 +24,6 @@ describe("SourceBubbles", () => { // reset fixture beforeEach(() => { $("body").html(""); - mockedPostJsonAsync.mockReset(); - mockedPostJsonAsync.mockResolvedValue(undefined); mockedPostString.mockReset(); }); afterEach(() => { @@ -335,48 +331,100 @@ describe("SourceBubbles", () => { expect(listItems[2].getAttribute("id")).toBe("es"); // alphabetical }); - it("MakeSourceTextDivForGroup reuses a current AI translation stored in the live group", () => { - const sourceText = "English text"; - const aiLanguageTag = "id-x-ai-google"; - const fingerprint = ( - BloomSourceBubbles as any - ).getAiSourceBubbleFingerprint(sourceText, "en", aiLanguageTag); + // --- AI source bubble display tests --- + // + // AI-translated source divs (lang tag like "fr-x-ai-deepl") are written into the book + // by a C# batch process that runs before the book is opened for editing. The front end + // is display-only: it never requests a translation, never writes an AI div, and never + // removes one from the live .bloom-translationGroup. These tests exercise only that + // display behavior. + + it("MakeSourceTextDivForGroup shows one tab per AI-provider div, alongside the normal source tabs", () => { const testHtml = $( [ "
    ", "
    English text
    ", - `
    Teks Indonesia
    `, + "
    Spanish text
    ", + "
    Texte français DeepL
    ", + "
    Texte français Google
    ", "
    ", ].join("\n"), ); $("body").append(testHtml); + + // sanity check: both AI divs are actually present with distinct text before we build the bubble + expect( + $("body").find("#testTarget div[lang='fr-x-ai-deepl']").text(), + ).toBe("Texte français DeepL"); + expect( + $("body").find("#testTarget div[lang='fr-x-ai-google']").text(), + ).toBe("Texte français Google"); + (window as any).GetSettings = () => ({ defaultSourceLanguage: "en", defaultSourceLanguage2: "", currentCollectionLanguage2: "", currentCollectionLanguage3: "", allowAiSourceBubbles: true, - aiSourceBubblesLanguageTag: aiLanguageTag, }); const result = BloomSourceBubbles.MakeSourceTextDivForGroup( $("body").find("#testTarget")[0], ); - expect(mockedPostJsonAsync).not.toHaveBeenCalled(); + // English is vernacular (no tab); es, fr-x-ai-deepl and fr-x-ai-google each get a tab + const listItems = result.find("nav ul li"); + expect(listItems.length).toBe(3); + expect(result.find("li#es").length).toBe(1); + expect(result.find("li#fr-x-ai-deepl").length).toBe(1); + expect(result.find("li#fr-x-ai-google").length).toBe(1); + expect( + result.find("div.source-text[lang='fr-x-ai-deepl']")[0] + .childNodes[0].textContent, + ).toBe("Texte français DeepL"); expect( - result.find(`div.source-text[lang='${aiLanguageTag}']`)[0] + result.find("div.source-text[lang='fr-x-ai-google']")[0] .childNodes[0].textContent, - ).toBe("Teks Indonesia"); + ).toBe("Texte français Google"); }); - it("MakeSourceTextDivForGroup shows an icon before AI language tab labels", () => { + it("getLanguageDisplayName labels an AI tab with the language name and mapped provider name", () => { + const getLanguageDisplayName = (BloomSourceBubbles as any) + .getLanguageDisplayName as (langTag: string) => string; + + expect(getLanguageDisplayName("fr-x-ai-deepl")).toBe( + "AI français (DeepL)", + ); + expect(getLanguageDisplayName("fr-x-ai-google")).toBe( + "AI français (Google Translate)", + ); + expect(getLanguageDisplayName("fr-x-ai-alpha2")).toBe( + "AI français (SIL Alpha2)", + ); + // an unrecognized provider id is shown as-is + expect(getLanguageDisplayName("fr-x-ai-mystery")).toBe( + "AI français (mystery)", + ); + }); + + it("getLanguageDisplayName falls back to the browser's language names for tags Bloom doesn't know", () => { + const getLanguageDisplayName = (BloomSourceBubbles as any) + .getLanguageDisplayName as (langTag: string) => string; + + // "de" is not one of the mocked collection languages, so Bloom's own lookup fails; + // Intl.DisplayNames should supply "German" rather than showing the raw tag. + expect(getLanguageDisplayName("de-x-ai-deepl")).toBe( + "AI German (DeepL)", + ); + }); + + it("MakeSourceTextDivForGroup shows the AI icon only on AI tabs, with a provider-labeled tab", () => { const testHtml = $( [ "
    ", "
    English text
    ", "
    French text
    ", - "
    Bulan dan Topi
    ", + "
    Texte français DeepL
    ", "
    ", ].join("\n"), ); @@ -388,113 +436,106 @@ describe("SourceBubbles", () => { currentCollectionLanguage2: "", currentCollectionLanguage3: "", allowAiSourceBubbles: true, - aiSourceBubblesLanguageTag: "id-x-ai-deepl", }); const result = BloomSourceBubbles.MakeSourceTextDivForGroup( $("body").find("#testTarget")[0], ); - const aiTab = result.find("li#id-x-ai-deepl a.sourceTextTab"); + const aiTab = result.find("li#fr-x-ai-deepl a.sourceTextTab"); expect(aiTab.length).toBe(1); expect(aiTab.find("svg[data-testid='AutoAwesomeIcon']").length).toBe(1); - expect(aiTab.text()).toContain("AI "); + expect(aiTab.text().trim()).toBe("AI français (DeepL)"); const nonAiTab = result.find("li#fr a.sourceTextTab"); + expect(nonAiTab.length).toBe(1); expect(nonAiTab.find("svg[data-testid='AutoAwesomeIcon']").length).toBe( 0, ); }); - it("MakeSourceTextDivForGroup does not start a second request while the same AI translation is pending", () => { - const aiLanguageTag = "id-x-ai-google"; - mockedPostJsonAsync.mockImplementation( - () => new Promise(() => undefined), - ); + it("MakeSourceTextDivForGroup hides AI tabs when allowAiSourceBubbles is false, without touching the live group", () => { const testHtml = $( [ "
    ", "
    English text
    ", + "
    Spanish text
    ", + "
    Texte français DeepL
    ", "
    ", ].join("\n"), ); $("body").append(testHtml); + const liveGroup = $("body").find("#testTarget"); + + // sanity check: the AI div is present in the live book DOM before we build the bubble + expect(liveGroup.find("div[lang='fr-x-ai-deepl']").length).toBe(1); + (window as any).GetSettings = () => ({ defaultSourceLanguage: "en", defaultSourceLanguage2: "", currentCollectionLanguage2: "", currentCollectionLanguage3: "", - allowAiSourceBubbles: true, - aiSourceBubblesLanguageTag: aiLanguageTag, + allowAiSourceBubbles: false, }); - const firstResult = BloomSourceBubbles.MakeSourceTextDivForGroup( - $("body").find("#testTarget")[0], - ); - const secondResult = BloomSourceBubbles.MakeSourceTextDivForGroup( - $("body").find("#testTarget")[0], + const result = BloomSourceBubbles.MakeSourceTextDivForGroup( + liveGroup[0], ); - expect(mockedPostJsonAsync).toHaveBeenCalledTimes(1); - expect( - firstResult.find(`div.source-text[lang='${aiLanguageTag}']`)[0] - .childNodes[0].textContent, - ).toBe("Translating..."); - expect( - secondResult.find(`div.source-text[lang='${aiLanguageTag}']`)[0] - .childNodes[0].textContent, - ).toBe("Translating..."); + expect(result.find("li#fr-x-ai-deepl").length).toBe(0); + expect(result.find("div[lang='fr-x-ai-deepl']").length).toBe(0); + expect(result.find("li#es").length).toBe(1); + // the book's own DOM must be untouched even though the setting is off + expect(liveGroup.find("div[lang='fr-x-ai-deepl']").length).toBe(1); + expect(liveGroup.find("div[lang='fr-x-ai-deepl']").text()).toBe( + "Texte français DeepL", + ); }); - it("MakeSourceTextDivForGroup ignores an AI default source language when choosing text to translate", () => { - const aiLanguageTag = "id-x-ai-deepl"; - mockedPostJsonAsync.mockImplementation( - () => new Promise(() => undefined), - ); + it("MakeSourceTextDivForGroup does not show an empty AI div as a tab", () => { const testHtml = $( [ "
    ", - "
    English text
    ", - "
    Tok Pisin text
    ", - `
    Old Indonesian
    `, + "
    English text
    ", + "
    Spanish text
    ", + "
    ", "
    ", ].join("\n"), ); $("body").append(testHtml); + + // sanity check: the AI div really is empty before we build the bubble + expect( + $("body") + .find("#testTarget div[lang='fr-x-ai-deepl']") + .text() + .trim(), + ).toBe(""); + (window as any).GetSettings = () => ({ - defaultSourceLanguage: aiLanguageTag, + defaultSourceLanguage: "en", defaultSourceLanguage2: "", - currentCollectionLanguage2: "tpi", + currentCollectionLanguage2: "", currentCollectionLanguage3: "", allowAiSourceBubbles: true, - aiSourceBubblesLanguageTag: aiLanguageTag, }); - BloomSourceBubbles.MakeSourceTextDivForGroup( + const result = BloomSourceBubbles.MakeSourceTextDivForGroup( $("body").find("#testTarget")[0], ); - expect(mockedPostJsonAsync).toHaveBeenCalledTimes(1); - expect(mockedPostJsonAsync.mock.calls[0][0]).toBe( - "aiSourceBubbles/translate", - ); - expect(mockedPostJsonAsync.mock.calls[0][1]).toEqual({ - sourceText: "Tok Pisin text", - sourceLanguageTag: "tpi", - }); + expect(result.find("li#fr-x-ai-deepl").length).toBe(0); + expect(result.find("div[lang='fr-x-ai-deepl']").length).toBe(0); + expect(result.find("li#es").length).toBe(1); }); it("AI source bubble tabs are remembered as the default source language", () => { const aiLanguageTag = "id-x-ai-deepl"; - const sourceText = "Tok Pisin text"; - const fingerprint = ( - BloomSourceBubbles as any - ).getAiSourceBubbleFingerprint(sourceText, "tpi", aiLanguageTag); const testHtml = $( [ "
    ", "
    Tok Pisin text
    ", - `
    Bahasa Indonesia
    `, + `
    Bahasa Indonesia
    `, "
    ", ].join("\n"), ); @@ -505,7 +546,6 @@ describe("SourceBubbles", () => { currentCollectionLanguage2: "", currentCollectionLanguage3: "", allowAiSourceBubbles: true, - aiSourceBubblesLanguageTag: aiLanguageTag, }); const result = BloomSourceBubbles.MakeSourceTextDivForGroup( @@ -568,68 +608,4 @@ describe("SourceBubbles", () => { "es", ); }); - - it("translateSourceBubbleAsync maps PascalCase API response properties", async () => { - mockedPostJsonAsync.mockResolvedValue({ - data: { - Text: "Bahasa Indonesia", - AiLanguageTag: "id-x-ai-deepl", - }, - } as any); - - const response = await ( - BloomSourceBubbles as any - ).translateSourceBubbleAsync("Tok Pisin text", "tpi"); - - expect(response).toEqual({ - text: "Bahasa Indonesia", - aiLanguageTag: "id-x-ai-deepl", - }); - }); - - it("MakeSourceTextDivForGroup syncs a stale visible qtip when the AI translation is already current", () => { - const aiLanguageTag = "id-x-ai-deepl"; - const sourceText = "The Moon and the Cap"; - const fingerprint = ( - BloomSourceBubbles as any - ).getAiSourceBubbleFingerprint(sourceText, "en", aiLanguageTag); - const testHtml = $( - [ - "
    ", - `
    ${sourceText}
    `, - `
    Bulan dan Topi
    `, - "
    ", - "
    ", - `
    Translating...
    `, - "
    ", - ].join("\n"), - ); - $("body").append(testHtml); - (window as any).GetSettings = () => ({ - defaultSourceLanguage: "en", - defaultSourceLanguage2: "", - currentCollectionLanguage2: "", - currentCollectionLanguage3: "", - allowAiSourceBubbles: true, - aiSourceBubblesLanguageTag: aiLanguageTag, - }); - - BloomSourceBubbles.MakeSourceTextDivForGroup( - $("body").find("#testTarget")[0], - ); - - const tooltipAiDiv = $("body").find( - "#qtip-0 .bloom-ai-source-bubble-translation", - ); - expect(tooltipAiDiv.text()).toBe("Bulan dan Topi"); - expect(tooltipAiDiv.attr("data-ai-source-bubble-fingerprint")).toBe( - fingerprint, - ); - expect( - tooltipAiDiv.attr("data-ai-source-bubble-pending-fingerprint"), - ).toBeUndefined(); - expect( - tooltipAiDiv.attr("data-ai-source-bubble-request-token"), - ).toBeUndefined(); - }); }); diff --git a/src/BloomBrowserUI/bookEdit/sourceBubbles/aiTranslationDisplay.ts b/src/BloomBrowserUI/bookEdit/sourceBubbles/aiTranslationDisplay.ts new file mode 100644 index 000000000000..965f18e2380b --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/sourceBubbles/aiTranslationDisplay.ts @@ -0,0 +1,53 @@ +// Small helpers for displaying AI-translated content in the source bubbles UI. The +// translations themselves are produced by a C# batch process (see AiTranslationBookUpdater) +// before the book is opened for editing; this module only concerns itself with how the +// front end labels and filters that AI-generated content once it's already in the DOM. +import $ from "jquery"; + +/// True if the given lang tag (e.g. "fr-x-ai-deepl") is one Bloom's AI translation machinery +/// wrote, per the "-x-ai" private-use convention (see AiTranslationService.GetAiLanguageTag). +export function isAiLanguageTag(languageTag: string | undefined): boolean { + return !!languageTag && languageTag.includes("-x-ai"); +} + +/// Maps a translation provider id (e.g. "deepl") to the human-facing name shown in AI tab +/// labels. Kept in sync with AiTranslationService.GetProviderDisplayName on the C# side. +export function getAiProviderDisplayName(providerId: string): string { + switch (providerId) { + case "deepl": + return "DeepL"; + case "google": + return "Google Translate"; + case "alpha2": + return "SIL Alpha2"; + default: + return providerId; + } +} + +/// The AI target language is typically not one of the book's languages, so Bloom's own +/// language name lookup often doesn't know it; the browser's Intl database usually does. +export function getLanguageNameFromBrowser( + langTag: string, +): string | undefined { + try { + return new Intl.DisplayNames([navigator.language || "en"], { + type: "language", + }).of(langTag); + } catch { + // Intl.DisplayNames throws on structurally invalid tags; fall back to the raw tag. + return undefined; + } +} + +/// Strips AI-translated divs (lang tag contains "-x-ai-") out of a cloned source-bubble div, +/// used when the user has turned AI source bubbles off. Only mutates the clone passed in; +/// the live .bloom-translationGroup in the book is never touched here. +export function removeAiTranslationDivsFromClone(divForBubble: JQuery): void { + divForBubble.find("div[lang]").each((index, element) => { + const langTag = element.getAttribute("lang") || ""; + if (isAiLanguageTag(langTag)) { + element.remove(); + } + }); +} diff --git a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx index ba495c9512ea..9a71e85ddd5a 100644 --- a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx +++ b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx @@ -1,3 +1,7 @@ +// This test harness deliberately mounts/unmounts via the React 17-style ReactDOM.render / +// unmountComponentAtNode API (synchronous, simple for these unit tests). Disable the +// React-18-deprecation rule for the file rather than migrate the harness to createRoot here. +/* eslint-disable react/no-deprecated */ import * as React from "react"; import ReactDOM from "react-dom"; import { act } from "react-dom/test-utils"; @@ -21,23 +25,53 @@ const { allowTeamCollection: false, allowAppBuilder: false, allowAiSourceBubbles: false, - aiSourceBubblesProvider: "deepl", - aiSourceBubblesTargetLanguageTag: "en", - aiSourceBubblesDeepLApiKey: "", - aiSourceBubblesGoogleServiceAccountEmail: "", - aiSourceBubblesGooglePrivateKey: "", + aiTranslation: { + targetLanguageTag: "en", + engines: [ + { + providerId: "deepl", + enabled: false, + apiKey: "", + serviceAccountEmail: "", + privateKey: "", + validation: { + succeeded: false, + message: "", + upToDate: false, + }, + }, + { + providerId: "google", + enabled: false, + apiKey: "", + serviceAccountEmail: "", + privateKey: "", + validation: { + succeeded: false, + message: "", + upToDate: false, + }, + }, + { + providerId: "alpha2", + enabled: false, + apiKey: "", + serviceAccountEmail: "", + privateKey: "", + validation: { + succeeded: false, + message: "", + upToDate: false, + }, + }, + ], + }, showQrCode: true, qrcodeCaption: "caption", - }, + } as Record, showAutoUpdate: true, showExperimentalBookSourcesOption: false, allowTeamCollectionEnabled: true, - aiSourceBubblesValidation: { - currentFingerprint: "", - validatedFingerprint: "", - succeeded: false, - message: "", - }, }, })); @@ -89,58 +123,89 @@ vi.mock("@sillsdev/config-r", () => ({ return (
    + + {props.children}
    @@ -152,18 +217,25 @@ vi.mock("@sillsdev/config-r", () => ({ ConfigrGroup: (props: React.PropsWithChildren) => (
    {props.children}
    ), - ConfigrBoolean: () => null, - ConfigrInput: () => null, + ConfigrBoolean: (props: { path: string; label: string }) => ( +
    {props.label}
    + ), + ConfigrInput: (props: { path: string; label: string }) => ( +
    {props.label}
    + ), ConfigrSelect: () => null, ConfigrCustomObjectInput: (props: { control: React.FunctionComponent<{ - value: string; + value: unknown; disabled?: boolean; - onChange: (value: string) => void; + onChange: (value: unknown) => void; }>; + overrideValue?: unknown; }) => { const Control = props.control; - return {}} />; + return ( + {}} /> + ); }, ConfigrCustomStringInput: (props: { control: React.FunctionComponent<{ @@ -178,7 +250,10 @@ vi.mock("@sillsdev/config-r", () => ({ })); import { AdvancedSettingsPanel } from "./AdvancedSettingsPanel"; -import { parseSupportedTargetLanguageOptions } from "./AiSourceBubblesSettingsGroup"; +import { + getLanguageSupportNote, + parseSupportedTargetLanguageOptions, +} from "./AiTranslationSettingsGroup"; describe("AdvancedSettingsPanel", () => { let container: HTMLDivElement; @@ -204,45 +279,85 @@ describe("AdvancedSettingsPanel", () => { allowTeamCollection: false, allowAppBuilder: false, allowAiSourceBubbles: false, - aiSourceBubblesProvider: "deepl", - aiSourceBubblesTargetLanguageTag: "en", - aiSourceBubblesDeepLApiKey: "", - aiSourceBubblesGoogleServiceAccountEmail: "", - aiSourceBubblesGooglePrivateKey: "", + aiTranslation: { + targetLanguageTag: "en", + engines: [ + { + providerId: "deepl", + enabled: false, + apiKey: "", + serviceAccountEmail: "", + privateKey: "", + validation: { + succeeded: false, + message: "", + upToDate: false, + }, + }, + { + providerId: "google", + enabled: false, + apiKey: "", + serviceAccountEmail: "", + privateKey: "", + validation: { + succeeded: false, + message: "", + upToDate: false, + }, + }, + { + providerId: "alpha2", + enabled: false, + apiKey: "", + serviceAccountEmail: "", + privateKey: "", + validation: { + succeeded: false, + message: "", + upToDate: false, + }, + }, + ], + }, showQrCode: true, qrcodeCaption: "caption", }; - initialAdvancedSettingsData.aiSourceBubblesValidation = { - currentFingerprint: "", - validatedFingerprint: "", - succeeded: false, - message: "", - }; - mockPostJsonAsync.mockImplementation(async (endpoint: string) => { - if (endpoint === "settings/validateAiSourceBubbles") { - return { - data: { - currentFingerprint: "fingerprint", - validatedFingerprint: "fingerprint", - succeeded: true, - message: "La lectura es importante", - }, - }; - } - - if (endpoint === "settings/aiSourceBubblesSupportedLanguages") { - return { - data: { - languages: [ - { Value: "es", Label: "Spanish" }, - { Value: "fra", Label: "French (fra)" }, - ], - }, - }; - } - - throw new Error(`Unexpected async POST endpoint: ${endpoint}`); - }); + mockPostJsonAsync.mockImplementation( + async (endpoint: string, body?: unknown) => { + if (endpoint === "settings/validateAiTranslationEngine") { + const providerId = (body as { providerId: string }) + .providerId; + return { + data: { + succeeded: true, + message: `translated by ${providerId}`, + }, + }; + } + + if (endpoint === "settings/aiTranslationSupportedLanguages") { + return { + data: { + languages: [ + { + tag: "es", + name: "Spanish", + providerIds: ["deepl", "google"], + }, + { + tag: "fra", + name: "French (fra)", + providerIds: ["deepl"], + }, + ], + }, + }; + } + + throw new Error(`Unexpected async POST endpoint: ${endpoint}`); + }, + ); }); afterEach(() => { @@ -252,41 +367,125 @@ describe("AdvancedSettingsPanel", () => { vi.useRealTimers(); }); - it("debounces AI validation and renders the translated probe result", async () => { + it("shows an engine's credential fields only once it is enabled", async () => { await act(async () => { ReactDOM.render(, container); }); - click('[data-testid="set-google-config"]'); + expect( + container.querySelector( + '[data-testid="configr-input-aiTranslationGoogleServiceAccountEmail"]', + ), + ).toBeNull(); + + click('[data-testid="enable-google"]'); + + expect( + container.querySelector( + '[data-testid="configr-input-aiTranslationGoogleServiceAccountEmail"]', + ), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="configr-input-aiTranslationGooglePrivateKey"]', + ), + ).not.toBeNull(); + // DeepL and Alpha2 were never enabled, so their credential fields stay hidden. + expect( + container.querySelector( + '[data-testid="configr-input-aiTranslationDeepLApiKey"]', + ), + ).toBeNull(); + expect( + container.querySelector( + '[data-testid="configr-input-aiTranslationAlpha2ApiKey"]', + ), + ).toBeNull(); + // The three enable toggles are always present, regardless of enabled state. + expect( + container.querySelector( + '[data-testid="configr-boolean-aiTranslationDeepLEnabled"]', + ), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="configr-boolean-aiTranslationAlpha2Enabled"]', + ), + ).not.toBeNull(); + }); + + it("posts the store payload in the pinned nested wire shape", async () => { + await act(async () => { + ReactDOM.render(, container); + }); + + click('[data-testid="enable-all-three"]'); expect(mockPostJson).toHaveBeenCalledWith( "settings/advancedProgramSettings", expect.objectContaining({ allowAiSourceBubbles: true, - aiSourceBubblesProvider: "google", - aiSourceBubblesTargetLanguageTag: "es", - aiSourceBubblesGoogleServiceAccountEmail: - "service-account@example.com", + aiTranslation: { + targetLanguageTag: "es", + engines: [ + // Google's serviceAccountEmail/privateKey must NOT leak onto the other + // engines' records (doing so wiped their validation when Google creds + // were edited); non-google engines send empty Google fields. + expect.objectContaining({ + providerId: "deepl", + enabled: true, + apiKey: "deepl-key", + serviceAccountEmail: "", + privateKey: "", + }), + expect.objectContaining({ + providerId: "google", + enabled: true, + serviceAccountEmail: "service-account@example.com", + privateKey: + "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + }), + expect.objectContaining({ + providerId: "alpha2", + enabled: true, + apiKey: "alpha2-key", + serviceAccountEmail: "", + privateKey: "", + }), + ], + }, }), ); + // The flat, Configr-internal AI keys must not leak into the wire payload. + const [, wirePayload] = mockPostJson.mock.calls[0]; + expect(wirePayload).not.toHaveProperty("aiTranslationGoogleEnabled"); + expect(wirePayload).not.toHaveProperty( + "aiTranslationTargetLanguageTag", + ); + }); + + it("debounces per-engine validation and posts only the providerId", async () => { + await act(async () => { + ReactDOM.render(, container); + }); + + click('[data-testid="enable-google"]'); + + // Only google is enabled+credentialed, so only it should be probed. await act(async () => { await vi.advanceTimersByTimeAsync(601); }); expect(mockPostJsonAsync).toHaveBeenCalledWith( - "settings/validateAiSourceBubbles", - expect.objectContaining({ - allowAiSourceBubbles: true, - aiSourceBubblesProvider: "google", - aiSourceBubblesTargetLanguageTag: "es", - aiSourceBubblesGoogleServiceAccountEmail: - "service-account@example.com", - }), + "settings/validateAiTranslationEngine", + { providerId: "google" }, ); - expect(container.textContent).toContain( - '"Today a reader, tomorrow a leader." --> La lectura es importante', + expect(mockPostJsonAsync).not.toHaveBeenCalledWith( + "settings/validateAiTranslationEngine", + { providerId: "deepl" }, ); + expect(container.textContent).toContain("translated by google"); click('[data-testid="set-french-target"]'); @@ -297,48 +496,68 @@ describe("AdvancedSettingsPanel", () => { }); expect(mockPostJsonAsync).toHaveBeenCalledWith( - "settings/validateAiSourceBubbles", - expect.objectContaining({ - allowAiSourceBubbles: true, - aiSourceBubblesProvider: "google", - aiSourceBubblesTargetLanguageTag: "fr", - aiSourceBubblesGoogleServiceAccountEmail: - "service-account@example.com", - }), + "settings/validateAiTranslationEngine", + { providerId: "google" }, ); }); - it("clears the previous translation result immediately and waits for a target language before rerunning", async () => { + it("clears validation immediately when the target language is removed, and waits for one before re-validating", async () => { initialAdvancedSettingsData.values = { ...initialAdvancedSettingsData.values, allowAiSourceBubbles: true, - aiSourceBubblesProvider: "google", - aiSourceBubblesTargetLanguageTag: "es", - aiSourceBubblesGoogleServiceAccountEmail: - "service-account@example.com", - aiSourceBubblesGooglePrivateKey: - "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", - }; - initialAdvancedSettingsData.aiSourceBubblesValidation = { - currentFingerprint: "loaded-fingerprint", - validatedFingerprint: "loaded-fingerprint", - succeeded: true, - message: "La lectura es importante", + aiTranslation: { + targetLanguageTag: "es", + engines: [ + { + providerId: "deepl", + enabled: false, + apiKey: "", + serviceAccountEmail: "", + privateKey: "", + validation: { + succeeded: false, + message: "", + upToDate: false, + }, + }, + { + providerId: "google", + enabled: true, + apiKey: "", + serviceAccountEmail: "service-account@example.com", + privateKey: + "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + validation: { + succeeded: true, + message: "translated by google", + upToDate: true, + }, + }, + { + providerId: "alpha2", + enabled: false, + apiKey: "", + serviceAccountEmail: "", + privateKey: "", + validation: { + succeeded: false, + message: "", + upToDate: false, + }, + }, + ], + }, }; await act(async () => { ReactDOM.render(, container); }); - expect(container.textContent).toContain( - '"Today a reader, tomorrow a leader." --> La lectura es importante', - ); + expect(container.textContent).toContain("translated by google"); - click('[data-testid="set-google-without-target"]'); + click('[data-testid="enable-google-without-target"]'); - expect(container.textContent).not.toContain( - '"Today a reader, tomorrow a leader." --> La lectura es importante', - ); + expect(container.textContent).not.toContain("translated by google"); expect(container.textContent).not.toContain("Testing translation..."); await act(async () => { @@ -346,11 +565,8 @@ describe("AdvancedSettingsPanel", () => { }); expect(mockPostJsonAsync).not.toHaveBeenCalledWith( - "settings/validateAiSourceBubbles", - expect.objectContaining({ - aiSourceBubblesProvider: "google", - aiSourceBubblesTargetLanguageTag: "", - }), + "settings/validateAiTranslationEngine", + { providerId: "google" }, ); click('[data-testid="set-french-target"]'); @@ -362,26 +578,20 @@ describe("AdvancedSettingsPanel", () => { }); expect(mockPostJsonAsync).toHaveBeenCalledWith( - "settings/validateAiSourceBubbles", - expect.objectContaining({ - allowAiSourceBubbles: true, - aiSourceBubblesProvider: "google", - aiSourceBubblesTargetLanguageTag: "fr", - aiSourceBubblesGoogleServiceAccountEmail: - "service-account@example.com", - }), + "settings/validateAiTranslationEngine", + { providerId: "google" }, ); }); - it("fetches provider-supported languages for the target language selector", async () => { + it("fetches the union of provider-supported languages when the ready engine set changes", async () => { await act(async () => { ReactDOM.render(, container); }); - click('[data-testid="set-google-config"]'); + click('[data-testid="enable-deepl-and-google"]'); const targetLanguageSelect = container.querySelector( - '[data-testid="ai-source-bubbles-target-language-select"]', + '[data-testid="ai-translation-target-language-select"]', ) as HTMLElement; expect(targetLanguageSelect).not.toBeNull(); @@ -392,24 +602,45 @@ describe("AdvancedSettingsPanel", () => { }); expect(mockPostJsonAsync).toHaveBeenCalledWith( - "settings/aiSourceBubblesSupportedLanguages", - expect.objectContaining({ - allowAiSourceBubbles: true, - aiSourceBubblesProvider: "google", - aiSourceBubblesGoogleServiceAccountEmail: - "service-account@example.com", - }), + "settings/aiTranslationSupportedLanguages", + expect.anything(), ); expect( parseSupportedTargetLanguageOptions({ languages: [ - { Value: "es", Label: "Spanish (es)" } as unknown as { - value: string; - label: string; - }, + { tag: "es", name: "Spanish (es)", providerIds: ["deepl"] }, ], }), - ).toEqual([{ value: "es", label: "Spanish" }]); + ).toEqual([{ value: "es", label: "Spanish", providerIds: ["deepl"] }]); + }); + + it("notes when a language is only supported by some of the ready engines", () => { + const spanish = { + value: "es", + label: "Spanish", + providerIds: ["deepl", "google"] as const, + }; + const french = { + value: "fra", + label: "French", + providerIds: ["deepl"] as const, + }; + const displayNames = { + deepl: "DeepL", + google: "Google Translate", + alpha2: "SIL Alpha2", + }; + + expect( + getLanguageSupportNote(spanish, ["deepl", "google"], displayNames), + ).toBe(""); + expect( + getLanguageSupportNote(french, ["deepl", "google"], displayNames), + ).toBe("DeepL"); + // Supported by none of the ready engines: no note (won't appear as an option anyway). + expect(getLanguageSupportNote(french, ["google"], displayNames)).toBe( + "", + ); }); }); diff --git a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx index 67f675fa7457..0b8ee67209c1 100644 --- a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx +++ b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx @@ -15,24 +15,45 @@ import { useGetFeatureStatus } from "../react_components/featureStatus"; import { BloomSubscriptionIndicatorIconAndText } from "../react_components/requiresSubscription"; import { useL10n } from "../react_components/l10nHooks"; import { - IAiSourceBubblesSettings, - IAiSourceBubblesValidationState, - useAiSourceBubblesSettingsGroup, -} from "./AiSourceBubblesSettingsGroup"; + AiTranslationProviderId, + IAiTranslationEngineValidation, + IAiTranslationSettings, + IAiTranslationWireSettings, + buildAiTranslationWirePayload, + extractAiTranslationFlatSettings, + flattenAiTranslationWireSettings, + omitAiTranslationFlatSettings, + useAiTranslationSettingsGroup, +} from "./AiTranslationSettingsGroup"; + +// The shape actually sent/received over the wire for settings/advancedProgramSettings: the +// flat, non-AI settings plus a nested "aiTranslation" object matching the backend contract. +interface IAdvancedSettingsWire { + autoUpdate?: boolean; + showExperimentalBookSources?: boolean; + allowTeamCollection?: boolean; + allowAppBuilder?: boolean; + allowAiSourceBubbles?: boolean; + showQrCode?: boolean; + qrcodeCaption?: string; + aiTranslation?: IAiTranslationWireSettings; +} interface IAdvancedSettingsApiData { - values: IAdvancedSettings; + values: IAdvancedSettingsWire; showAutoUpdate?: boolean; showExperimentalBookSourcesOption?: boolean; allowTeamCollectionEnabled?: boolean; - aiSourceBubblesValidation?: IAiSourceBubblesValidationState; } -interface IAdvancedSettings extends IAiSourceBubblesSettings { +// The flattened shape Configr actually edits: non-AI settings plus one boolean+credential set +// per AI engine (see AiTranslationSettingsGroup for why it's flattened rather than nested). +interface IAdvancedSettings extends IAiTranslationSettings { autoUpdate?: boolean; showExperimentalBookSources?: boolean; allowTeamCollection?: boolean; allowAppBuilder?: boolean; + allowAiSourceBubbles?: boolean; showQrCode?: boolean; qrcodeCaption?: string; } @@ -48,8 +69,15 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { showExperimentalBookSourcesOption, setShowExperimentalBookSourcesOption, ] = React.useState(false); - const [aiSourceBubblesValidation, setAiSourceBubblesValidation] = - React.useState(); + const [ + aiTranslationInitialValidations, + setAiTranslationInitialValidations, + ] = + React.useState< + Partial< + Record + > + >(); const advancedProgramSettingsLabel = useL10n( "Advanced Program Settings", @@ -83,31 +111,43 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { "AI Source Bubbles", "CollectionSettingsDialog.AdvancedTab.Experimental.AiSourceBubbles", ); - const aiSourceBubblesSectionLabel = useL10n( + const aiTranslationSectionLabel = useL10n( "AI Source Bubbles", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.SectionLabel", ); - const aiSourceBubblesProviderLabel = useL10n( - "Provider", - "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.ProviderLabel", - ); - const aiSourceBubblesTargetLanguageLabel = useL10n( + const aiTranslationTargetLanguageLabel = useL10n( "Target Language", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TargetLanguageLabel", ); - const aiSourceBubblesDeepLApiKeyLabel = useL10n( + const aiTranslationDeepLEnabledLabel = useL10n( + "DeepL", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLEnabledLabel", + ); + const aiTranslationDeepLApiKeyLabel = useL10n( "DeepL API Key", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLApiKeyLabel", ); - const aiSourceBubblesGoogleServiceAccountEmailLabel = useL10n( + const aiTranslationGoogleEnabledLabel = useL10n( + "Google Translate", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GoogleEnabledLabel", + ); + const aiTranslationGoogleServiceAccountEmailLabel = useL10n( "Google Service Account Email", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GoogleServiceAccountEmailLabel", ); - const aiSourceBubblesGooglePrivateKeyLabel = useL10n( + const aiTranslationGooglePrivateKeyLabel = useL10n( "Google Service Account Private Key", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GooglePrivateKeyLabel", ); - const aiSourceBubblesTranslationTestLabel = useL10n( + const aiTranslationAlpha2EnabledLabel = useL10n( + "SIL Alpha2", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2EnabledLabel", + ); + const aiTranslationAlpha2ApiKeyLabel = useL10n( + "Alpha2 API key", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2ApiKeyLabel", + ); + const aiTranslationTranslationTestLabel = useL10n( "Translation Test", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TranslationTestLabel", ); @@ -172,7 +212,14 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { data = JSON.parse(result.data); } const advancedSettingsData = data as IAdvancedSettingsApiData; - setSettings(advancedSettingsData.values); + const { aiTranslation, ...restOfWireValues } = + advancedSettingsData.values; + const { flatSettings, initialValidations } = + flattenAiTranslationWireSettings(aiTranslation); + setSettings({ + ...restOfWireValues, + ...flatSettings, + } as IAdvancedSettings); setShowAutoUpdate(advancedSettingsData.showAutoUpdate ?? false); setAllowTeamCollectionEnabled( advancedSettingsData.allowTeamCollectionEnabled ?? false, @@ -180,23 +227,36 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { setShowExperimentalBookSourcesOption( advancedSettingsData.showExperimentalBookSourcesOption ?? false, ); - setAiSourceBubblesValidation( - advancedSettingsData.aiSourceBubblesValidation, - ); + setAiTranslationInitialValidations(initialValidations); }); }, []); - const aiSourceBubblesSettingsGroup = useAiSourceBubblesSettingsGroup({ - settings, - initialValidation: aiSourceBubblesValidation, - groupLabel: aiSourceBubblesSectionLabel, - providerLabel: aiSourceBubblesProviderLabel, - targetLanguageLabel: aiSourceBubblesTargetLanguageLabel, - deepLApiKeyLabel: aiSourceBubblesDeepLApiKeyLabel, + // Memoized so the object reference is stable across renders that don't actually change + // the AI settings; the group's validation/language-fetch effects key off this reference. + const aiTranslationFlatSettings = React.useMemo( + () => + settings + ? extractAiTranslationFlatSettings( + settings as unknown as Record, + ) + : undefined, + [settings], + ); + + const aiTranslationSettingsGroup = useAiTranslationSettingsGroup({ + settings: aiTranslationFlatSettings, + initialValidations: aiTranslationInitialValidations, + groupLabel: aiTranslationSectionLabel, + targetLanguageLabel: aiTranslationTargetLanguageLabel, + deepLEnabledLabel: aiTranslationDeepLEnabledLabel, + deepLApiKeyLabel: aiTranslationDeepLApiKeyLabel, + googleEnabledLabel: aiTranslationGoogleEnabledLabel, googleServiceAccountEmailLabel: - aiSourceBubblesGoogleServiceAccountEmailLabel, - googlePrivateKeyLabel: aiSourceBubblesGooglePrivateKeyLabel, - translationTestLabel: aiSourceBubblesTranslationTestLabel, + aiTranslationGoogleServiceAccountEmailLabel, + googlePrivateKeyLabel: aiTranslationGooglePrivateKeyLabel, + alpha2EnabledLabel: aiTranslationAlpha2EnabledLabel, + alpha2ApiKeyLabel: aiTranslationAlpha2ApiKeyLabel, + translationTestLabel: aiTranslationTranslationTestLabel, }); return ( @@ -231,9 +291,28 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { normalizeConfigrSettings(newSettings); if (normalized) { setSettings(normalized); + const aiFlatSettings = + extractAiTranslationFlatSettings( + normalized as unknown as Record< + string, + unknown + >, + ); + const wirePayload = { + ...omitAiTranslationFlatSettings( + normalized as unknown as Record< + string, + unknown + >, + ), + aiTranslation: + buildAiTranslationWirePayload( + aiFlatSettings, + ), + }; postJson( "settings/advancedProgramSettings", - normalized, + wirePayload, ); } }} @@ -359,7 +438,7 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { {settings.allowAiSourceBubbles && - aiSourceBubblesSettingsGroup} + aiTranslationSettingsGroup} )} diff --git a/src/BloomBrowserUI/collection/AiSourceBubblesSettingsGroup.tsx b/src/BloomBrowserUI/collection/AiSourceBubblesSettingsGroup.tsx deleted file mode 100644 index 5c9f7c922842..000000000000 --- a/src/BloomBrowserUI/collection/AiSourceBubblesSettingsGroup.tsx +++ /dev/null @@ -1,518 +0,0 @@ -import { css } from "@emotion/react"; -import * as React from "react"; -import { - ConfigrCustomObjectInput, - ConfigrGroup, - ConfigrInput, - ConfigrSelect, -} from "@sillsdev/config-r"; -import { MenuItem, TextField } from "@mui/material"; -import { postJsonAsync } from "../utils/bloomApi"; - -export interface ITargetLanguageOption { - value: string; - label: string; -} - -export interface IAiSourceBubblesValidationState { - currentFingerprint?: string; - validatedFingerprint?: string; - succeeded?: boolean; - message?: string; -} - -export interface IAiSourceBubblesSettings { - allowAiSourceBubbles?: boolean; - aiSourceBubblesProvider?: string; - aiSourceBubblesTargetLanguageTag?: string; - aiSourceBubblesDeepLApiKey?: string; - aiSourceBubblesGoogleServiceAccountEmail?: string; - aiSourceBubblesGooglePrivateKey?: string; -} - -interface IAiSourceBubblesSupportedLanguagesResponse { - languages?: ITargetLanguageOption[]; - message?: string; -} - -export function parseSupportedTargetLanguageOptions( - data?: IAiSourceBubblesSupportedLanguagesResponse, -): ITargetLanguageOption[] { - const rawLanguages = data?.languages; - if (!Array.isArray(rawLanguages)) { - return []; - } - - return rawLanguages - .map((language) => { - const candidate = language as { - value?: string; - label?: string; - Value?: string; - Label?: string; - }; - const value = candidate.value ?? candidate.Value ?? ""; - const rawLabel = candidate.label ?? candidate.Label ?? value; - const labelSuffix = ` (${value})`; - const label = rawLabel.endsWith(labelSuffix) - ? rawLabel.substring(0, rawLabel.length - labelSuffix.length) - : rawLabel; - - if (!value) { - return undefined; - } - - return { - value, - label, - }; - }) - .filter((language): language is ITargetLanguageOption => !!language); -} - -function hasAiSourceBubblesRequiredConfig( - settingsValue?: IAiSourceBubblesSettings, -): boolean { - if (!settingsValue?.allowAiSourceBubbles) { - return false; - } - - if (!settingsValue.aiSourceBubblesProvider) { - return false; - } - - if (settingsValue.aiSourceBubblesProvider === "google") { - return !!( - settingsValue.aiSourceBubblesGoogleServiceAccountEmail?.trim() && - settingsValue.aiSourceBubblesGooglePrivateKey?.trim() - ); - } - - return !!settingsValue.aiSourceBubblesDeepLApiKey?.trim(); -} - -function hasAiSourceBubblesRequiredValidationConfig( - settingsValue?: IAiSourceBubblesSettings, -): boolean { - return ( - hasAiSourceBubblesRequiredConfig(settingsValue) && - !!settingsValue?.aiSourceBubblesTargetLanguageTag?.trim() - ); -} - -function usesProviderManagedTargetLanguages( - settingsValue?: IAiSourceBubblesSettings, -): boolean { - return !!settingsValue?.aiSourceBubblesProvider; -} - -function getAiSourceBubblesProbeKey( - settingsValue?: IAiSourceBubblesSettings, -): string { - return JSON.stringify({ - allowAiSourceBubbles: settingsValue?.allowAiSourceBubbles ?? false, - aiSourceBubblesProvider: settingsValue?.aiSourceBubblesProvider ?? "", - aiSourceBubblesTargetLanguageTag: - settingsValue?.aiSourceBubblesTargetLanguageTag ?? "", - aiSourceBubblesDeepLApiKey: - settingsValue?.aiSourceBubblesDeepLApiKey ?? "", - aiSourceBubblesGoogleServiceAccountEmail: - settingsValue?.aiSourceBubblesGoogleServiceAccountEmail ?? "", - aiSourceBubblesGooglePrivateKey: - settingsValue?.aiSourceBubblesGooglePrivateKey ?? "", - }); -} - -function getAiSourceBubblesLanguageConfigKey( - settingsValue?: IAiSourceBubblesSettings, -): string { - return JSON.stringify({ - allowAiSourceBubbles: settingsValue?.allowAiSourceBubbles ?? false, - aiSourceBubblesProvider: settingsValue?.aiSourceBubblesProvider ?? "", - aiSourceBubblesDeepLApiKey: - settingsValue?.aiSourceBubblesDeepLApiKey ?? "", - aiSourceBubblesGoogleServiceAccountEmail: - settingsValue?.aiSourceBubblesGoogleServiceAccountEmail ?? "", - aiSourceBubblesGooglePrivateKey: - settingsValue?.aiSourceBubblesGooglePrivateKey ?? "", - }); -} - -function parseAiSourceBubblesValidationState( - data: unknown, -): IAiSourceBubblesValidationState | undefined { - if (!data || typeof data !== "object") { - return undefined; - } - - const candidate = data as { - currentFingerprint?: string; - CurrentFingerprint?: string; - validatedFingerprint?: string; - ValidatedFingerprint?: string; - succeeded?: boolean; - Succeeded?: boolean; - message?: string; - Message?: string; - configurationFingerprint?: string; - ConfigurationFingerprint?: string; - }; - return { - currentFingerprint: - candidate.currentFingerprint || - candidate.CurrentFingerprint || - candidate.configurationFingerprint || - candidate.ConfigurationFingerprint, - validatedFingerprint: - candidate.validatedFingerprint || - candidate.ValidatedFingerprint || - candidate.configurationFingerprint || - candidate.ConfigurationFingerprint, - succeeded: candidate.succeeded ?? candidate.Succeeded, - message: candidate.message || candidate.Message, - }; -} - -export const useAiSourceBubblesSettingsGroup = (props: { - settings: IAiSourceBubblesSettings | undefined; - initialValidation?: IAiSourceBubblesValidationState; - groupLabel: string; - providerLabel: string; - targetLanguageLabel: string; - deepLApiKeyLabel: string; - googleServiceAccountEmailLabel: string; - googlePrivateKeyLabel: string; - translationTestLabel: string; -}): React.ReactElement => { - const [aiSourceBubblesValidation, setAiSourceBubblesValidation] = - React.useState( - props.initialValidation, - ); - const [ - isAiSourceBubblesValidationPending, - setIsAiSourceBubblesValidationPending, - ] = React.useState(false); - const [supportedTargetLanguages, setSupportedTargetLanguages] = - React.useState([]); - const [supportedLanguagesMessage, setSupportedLanguagesMessage] = - React.useState(""); - const [isLoadingSupportedLanguages, setIsLoadingSupportedLanguages] = - React.useState(false); - const [languageOptionsVersion, setLanguageOptionsVersion] = - React.useState(0); - const lastAiSourceBubblesProbeKeyRef = React.useRef(""); - const lastSupportedLanguagesConfigKeyRef = React.useRef(""); - const latestSettingsRef = React.useRef(props.settings); - latestSettingsRef.current = props.settings; - - React.useEffect(() => { - setAiSourceBubblesValidation(props.initialValidation); - const loadedProbeKey = getAiSourceBubblesProbeKey( - latestSettingsRef.current, - ); - if ( - props.initialValidation?.validatedFingerprint && - props.initialValidation.validatedFingerprint === - props.initialValidation.currentFingerprint && - props.initialValidation.message - ) { - lastAiSourceBubblesProbeKeyRef.current = loadedProbeKey; - } else { - lastAiSourceBubblesProbeKeyRef.current = ""; - } - }, [props.initialValidation]); - - const loadSupportedLanguages = React.useCallback(async () => { - const languageConfigKey = getAiSourceBubblesLanguageConfigKey( - props.settings, - ); - if (!usesProviderManagedTargetLanguages(props.settings)) { - setSupportedTargetLanguages([]); - setSupportedLanguagesMessage(""); - lastSupportedLanguagesConfigKeyRef.current = languageConfigKey; - return; - } - - if (!hasAiSourceBubblesRequiredConfig(props.settings)) { - setSupportedTargetLanguages([]); - setSupportedLanguagesMessage(""); - lastSupportedLanguagesConfigKeyRef.current = ""; - return; - } - - if ( - languageConfigKey === lastSupportedLanguagesConfigKeyRef.current && - supportedTargetLanguages.length > 0 - ) { - return; - } - - setIsLoadingSupportedLanguages(true); - setSupportedLanguagesMessage(""); - try { - const response = await postJsonAsync( - "settings/aiSourceBubblesSupportedLanguages", - props.settings, - ); - const data = response?.data as - | IAiSourceBubblesSupportedLanguagesResponse - | undefined; - const languages = parseSupportedTargetLanguageOptions(data); - setSupportedTargetLanguages(languages); - setSupportedLanguagesMessage(data?.message ?? ""); - lastSupportedLanguagesConfigKeyRef.current = languageConfigKey; - setLanguageOptionsVersion((value) => value + 1); - } finally { - setIsLoadingSupportedLanguages(false); - } - }, [props.settings, supportedTargetLanguages.length]); - - React.useEffect(() => { - const currentLanguageConfigKey = getAiSourceBubblesLanguageConfigKey( - props.settings, - ); - if ( - currentLanguageConfigKey !== - lastSupportedLanguagesConfigKeyRef.current - ) { - setSupportedTargetLanguages([]); - setSupportedLanguagesMessage(""); - } - }, [props.settings]); - - // This effect is necessary because provider-backed target-language lists depend on - // external credentials and should be ready as soon as the current provider config is usable. - React.useEffect(() => { - if (!hasAiSourceBubblesRequiredConfig(props.settings)) { - return; - } - - void loadSupportedLanguages(); - }, [loadSupportedLanguages, props.settings]); - - // This effect is necessary because validation must synchronize the current Settings form values - // with the backend/provider after the user stops typing relevant AI configuration fields. - React.useEffect(() => { - if (!props.settings?.allowAiSourceBubbles) { - setIsAiSourceBubblesValidationPending(false); - return; - } - - const probeKey = getAiSourceBubblesProbeKey(props.settings); - if (probeKey === lastAiSourceBubblesProbeKeyRef.current) { - return; - } - - lastAiSourceBubblesProbeKeyRef.current = probeKey; - if (!hasAiSourceBubblesRequiredValidationConfig(props.settings)) { - setIsAiSourceBubblesValidationPending(false); - setAiSourceBubblesValidation(undefined); - return; - } - - setIsAiSourceBubblesValidationPending(true); - setAiSourceBubblesValidation(undefined); - - let cancelled = false; - const timeoutId = window.setTimeout(() => { - void (async () => { - try { - const response = await postJsonAsync( - "settings/validateAiSourceBubbles", - props.settings, - ); - if (cancelled) { - return; - } - - setAiSourceBubblesValidation( - parseAiSourceBubblesValidationState(response?.data), - ); - } finally { - if (!cancelled) { - setIsAiSourceBubblesValidationPending(false); - } - } - })(); - }, 600); - - return () => { - cancelled = true; - window.clearTimeout(timeoutId); - }; - }, [props.settings]); - - const AiSourceBubblesTargetLanguageControl: React.FunctionComponent<{ - value: string; - disabled?: boolean; - onChange: (value: string) => void; - }> = (controlProps) => { - if (!usesProviderManagedTargetLanguages(props.settings)) { - return ( - { - controlProps.onChange(event.target.value); - }} - inputProps={{ - "data-testid": - "ai-source-bubbles-target-language-input", - }} - /> - ); - } - - const currentValue = controlProps.value || ""; - const knownOptions = supportedTargetLanguages.some( - (option) => option.value === currentValue, - ) - ? supportedTargetLanguages - : currentValue - ? [ - ...supportedTargetLanguages, - { - value: currentValue, - label: currentValue, - }, - ] - : supportedTargetLanguages; - - return ( - { - controlProps.onChange(event.target.value); - }} - SelectProps={{ - onOpen: () => { - void loadSupportedLanguages(); - }, - }} - inputProps={{ - "data-testid": "ai-source-bubbles-target-language-select", - "data-language-options-version": languageOptionsVersion, - }} - > - - {isLoadingSupportedLanguages && ( - - Loading languages... - - )} - {knownOptions.map((option) => ( - - {option.label} - - ))} - - ); - }; - - const validationText = React.useMemo(() => { - if (isAiSourceBubblesValidationPending) { - return { - text: "Testing translation...", - color: "#555", - }; - } - - if (!aiSourceBubblesValidation?.message) { - return { - text: "", - color: "#555", - }; - } - - return { - text: aiSourceBubblesValidation.succeeded - ? `\"Today a reader, tomorrow a leader.\" --> ${aiSourceBubblesValidation.message}` - : `Translation test failed: ${aiSourceBubblesValidation.message}`, - color: aiSourceBubblesValidation.succeeded ? "#2e7d32" : "#b3261e", - }; - }, [aiSourceBubblesValidation, isAiSourceBubblesValidationPending]); - - const AiSourceBubblesValidationStatusControl: React.FunctionComponent<{ - value: string; - disabled?: boolean; - onChange: (value: string) => void; - }> = () => { - return ( -
    - {validationText.text} -
    - ); - }; - - return ( - - - {props.settings?.aiSourceBubblesProvider === "deepl" && ( - - )} - {props.settings?.aiSourceBubblesProvider === "google" && ( - <> - - - - )} - - path="aiSourceBubblesTargetLanguageTag" - control={AiSourceBubblesTargetLanguageControl} - label={props.targetLanguageLabel} - /> - - path="aiSourceBubblesValidationMessage" - control={AiSourceBubblesValidationStatusControl} - label={props.translationTestLabel} - overrideValue={`${languageOptionsVersion}:${validationText.text}`} - /> - - ); -}; diff --git a/src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx b/src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx new file mode 100644 index 000000000000..2ee9a9cdeab0 --- /dev/null +++ b/src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx @@ -0,0 +1,868 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { + ConfigrBoolean, + ConfigrCustomObjectInput, + ConfigrGroup, + ConfigrInput, +} from "@sillsdev/config-r"; +import { MenuItem, TextField } from "@mui/material"; +import { postJsonAsync } from "../utils/bloomApi"; + +export type AiTranslationProviderId = "deepl" | "google" | "alpha2"; + +export interface ITargetLanguageOption { + value: string; + label: string; + providerIds: AiTranslationProviderId[]; +} + +export interface IAiTranslationEngineValidation { + succeeded: boolean; + message: string; + upToDate: boolean; +} + +// The flat shape our React/Configr state uses. One boolean+credential set per engine, +// plus the target language shared by all engines. This is translated to/from the nested +// wire contract (IAiTranslationWireSettings) at the AdvancedSettingsPanel API boundary. +export interface IAiTranslationSettings { + aiTranslationTargetLanguageTag?: string; + aiTranslationDeepLEnabled?: boolean; + aiTranslationDeepLApiKey?: string; + aiTranslationGoogleEnabled?: boolean; + aiTranslationGoogleServiceAccountEmail?: string; + aiTranslationGooglePrivateKey?: string; + aiTranslationAlpha2Enabled?: boolean; + aiTranslationAlpha2ApiKey?: string; +} + +// Every key of IAiTranslationSettings, used to split it out of / merge it into the +// larger IAdvancedSettings object that AdvancedSettingsPanel round-trips with the API. +export const aiTranslationFlatSettingsKeys = [ + "aiTranslationTargetLanguageTag", + "aiTranslationDeepLEnabled", + "aiTranslationDeepLApiKey", + "aiTranslationGoogleEnabled", + "aiTranslationGoogleServiceAccountEmail", + "aiTranslationGooglePrivateKey", + "aiTranslationAlpha2Enabled", + "aiTranslationAlpha2ApiKey", +] as const; + +export interface IAiTranslationWireEngineSettings { + providerId: AiTranslationProviderId; + enabled: boolean; + apiKey: string; + serviceAccountEmail: string; + privateKey: string; + validation: IAiTranslationEngineValidation; +} + +// The exact shape posted/received under the "aiTranslation" key of the advanced-settings payload. +export interface IAiTranslationWireSettings { + targetLanguageTag: string; + engines: IAiTranslationWireEngineSettings[]; +} + +interface IEngineFieldSpec { + providerId: AiTranslationProviderId; + enabledPath: keyof IAiTranslationSettings; + credentialPaths: Array; +} + +const deepLFieldSpec: IEngineFieldSpec = { + providerId: "deepl", + enabledPath: "aiTranslationDeepLEnabled", + credentialPaths: ["aiTranslationDeepLApiKey"], +}; + +const googleFieldSpec: IEngineFieldSpec = { + providerId: "google", + enabledPath: "aiTranslationGoogleEnabled", + credentialPaths: [ + "aiTranslationGoogleServiceAccountEmail", + "aiTranslationGooglePrivateKey", + ], +}; + +const alpha2FieldSpec: IEngineFieldSpec = { + providerId: "alpha2", + enabledPath: "aiTranslationAlpha2Enabled", + credentialPaths: ["aiTranslationAlpha2ApiKey"], +}; + +// Always exactly these three engines, in this order, matching the pinned backend contract. +const aiTranslationEngineSpecs: IEngineFieldSpec[] = [ + deepLFieldSpec, + googleFieldSpec, + alpha2FieldSpec, +]; + +interface IAiTranslationSupportedLanguagesResponse { + languages?: unknown[]; + message?: string; +} + +function readField( + candidate: Record, + camelCaseName: string, + pascalCaseName: string, +): T | undefined { + return (candidate[camelCaseName] ?? candidate[pascalCaseName]) as + | T + | undefined; +} + +// Parses the response of settings/aiTranslationSupportedLanguages. Tolerant of PascalCase +// field names since we don't control the exact casing the backend's JSON serializer produces. +export function parseSupportedTargetLanguageOptions( + data?: IAiTranslationSupportedLanguagesResponse, +): ITargetLanguageOption[] { + const rawLanguages = data?.languages; + if (!Array.isArray(rawLanguages)) { + return []; + } + + return rawLanguages + .map((language) => { + const candidate = language as Record; + const value = readField(candidate, "tag", "Tag") ?? ""; + const rawLabel = + readField(candidate, "name", "Name") ?? value; + const providerIds = + readField( + candidate, + "providerIds", + "ProviderIds", + ) ?? []; + const labelSuffix = ` (${value})`; + const label = rawLabel.endsWith(labelSuffix) + ? rawLabel.substring(0, rawLabel.length - labelSuffix.length) + : rawLabel; + + if (!value) { + return undefined; + } + + return { value, label, providerIds }; + }) + .filter((language): language is ITargetLanguageOption => !!language); +} + +function parseAiTranslationEngineValidation( + data: unknown, +): IAiTranslationEngineValidation | undefined { + if (!data || typeof data !== "object") { + return undefined; + } + + const candidate = data as Record; + return { + succeeded: + readField(candidate, "succeeded", "Succeeded") ?? false, + message: readField(candidate, "message", "Message") ?? "", + // A freshly-run validation is by definition current for the settings that produced it. + upToDate: true, + }; +} + +// Splits the nested wire settings for one engine's "validation" sub-object into our +// flat, tolerant-of-casing shape. +function parseWireEngineValidation( + data: unknown, +): IAiTranslationEngineValidation | undefined { + if (!data || typeof data !== "object") { + return undefined; + } + const candidate = data as Record; + return { + succeeded: + readField(candidate, "succeeded", "Succeeded") ?? false, + message: readField(candidate, "message", "Message") ?? "", + upToDate: + readField(candidate, "upToDate", "UpToDate") ?? false, + }; +} + +// Converts the nested wire contract (as received from settings/advancedProgramSettings) +// into our flat Configr-friendly settings plus a per-provider map of initial validation state. +export function flattenAiTranslationWireSettings( + wire: IAiTranslationWireSettings | undefined, +): { + flatSettings: IAiTranslationSettings; + initialValidations: Partial< + Record + >; +} { + const wireCandidate = (wire ?? {}) as unknown as Record; + const targetLanguageTag = + readField( + wireCandidate, + "targetLanguageTag", + "TargetLanguageTag", + ) ?? ""; + const rawEngines = + readField(wireCandidate, "engines", "Engines") ?? []; + + const flatSettings: IAiTranslationSettings = { + aiTranslationTargetLanguageTag: targetLanguageTag, + }; + const initialValidations: Partial< + Record + > = {}; + + aiTranslationEngineSpecs.forEach((spec) => { + const rawEngine = rawEngines + .map((engine) => engine as Record) + .find( + (engine) => + readField(engine, "providerId", "ProviderId") === + spec.providerId, + ); + + const enabled = + (rawEngine && + readField(rawEngine, "enabled", "Enabled")) ?? + false; + flatSettings[spec.enabledPath] = enabled as never; + + if (spec.providerId === "google") { + flatSettings.aiTranslationGoogleServiceAccountEmail = + (rawEngine && + readField( + rawEngine, + "serviceAccountEmail", + "ServiceAccountEmail", + )) ?? + ""; + flatSettings.aiTranslationGooglePrivateKey = + (rawEngine && + readField(rawEngine, "privateKey", "PrivateKey")) ?? + ""; + } else { + const apiKey = + (rawEngine && + readField(rawEngine, "apiKey", "ApiKey")) ?? + ""; + flatSettings[spec.credentialPaths[0]] = apiKey as never; + } + + const validation = parseWireEngineValidation( + rawEngine && readField(rawEngine, "validation", "Validation"), + ); + if (validation) { + initialValidations[spec.providerId] = validation; + } + }); + + return { flatSettings, initialValidations }; +} + +// Converts our flat Configr-friendly settings back into the nested wire contract for posting +// to settings/advancedProgramSettings. The validation sub-object is server-computed and ignored +// on store, so we send it back empty. +export function buildAiTranslationWirePayload( + flat: IAiTranslationSettings, +): IAiTranslationWireSettings { + return { + targetLanguageTag: flat.aiTranslationTargetLanguageTag ?? "", + engines: aiTranslationEngineSpecs.map((spec) => ({ + providerId: spec.providerId, + enabled: !!flat[spec.enabledPath], + apiKey: + spec.providerId === "google" + ? "" + : ((flat[spec.credentialPaths[0]] as string) ?? ""), + // Google's credentials belong only on the google engine record. Sending them on the + // deepl/alpha2 records too made the backend see those engines' credentials "change" + // whenever Google's were edited, wiping their validation (and silently dropping them + // from the active engines) -- and stored Google's private key in all three records. + serviceAccountEmail: + spec.providerId === "google" + ? (flat.aiTranslationGoogleServiceAccountEmail ?? "") + : "", + privateKey: + spec.providerId === "google" + ? (flat.aiTranslationGooglePrivateKey ?? "") + : "", + validation: { succeeded: false, message: "", upToDate: false }, + })), + }; +} + +// Pulls the flat AI-translation keys out of a larger settings object (e.g. IAdvancedSettings). +export function extractAiTranslationFlatSettings( + source: Record, +): IAiTranslationSettings { + const flat: Record = {}; + aiTranslationFlatSettingsKeys.forEach((key) => { + flat[key] = source[key]; + }); + return flat as IAiTranslationSettings; +} + +// The inverse of extractAiTranslationFlatSettings: returns a shallow copy of source with the +// flat AI-translation keys removed, ready to have a nested "aiTranslation" key added back in. +export function omitAiTranslationFlatSettings< + T extends Record, +>(source: T): T { + const clone: Record = { ...source }; + aiTranslationFlatSettingsKeys.forEach((key) => { + delete clone[key]; + }); + return clone as T; +} + +function isEngineEnabled( + settings: IAiTranslationSettings | undefined, + spec: IEngineFieldSpec, +): boolean { + return !!settings?.[spec.enabledPath]; +} + +function hasEngineCredentials( + settings: IAiTranslationSettings | undefined, + spec: IEngineFieldSpec, +): boolean { + return spec.credentialPaths.every( + (path) => !!(settings?.[path] as string | undefined)?.trim(), + ); +} + +function isEngineReady( + settings: IAiTranslationSettings | undefined, + spec: IEngineFieldSpec, +): boolean { + return ( + isEngineEnabled(settings, spec) && hasEngineCredentials(settings, spec) + ); +} + +function getReadyProviderIds( + settings: IAiTranslationSettings | undefined, +): AiTranslationProviderId[] { + return aiTranslationEngineSpecs + .filter((spec) => isEngineReady(settings, spec)) + .map((spec) => spec.providerId); +} + +function getEngineProbeKey( + settings: IAiTranslationSettings | undefined, + spec: IEngineFieldSpec, +): string { + return JSON.stringify({ + enabled: isEngineEnabled(settings, spec), + targetLanguageTag: settings?.aiTranslationTargetLanguageTag ?? "", + credentials: spec.credentialPaths.map((path) => settings?.[path] ?? ""), + }); +} + +// If option is supported by only some (not all, not none) of the ready (enabled+credentialed) +// engines, returns a comma-joined display-name note (e.g. "DeepL") to show next to that option +// in the target-language dropdown. Returns "" when no note is needed. +export function getLanguageSupportNote( + option: ITargetLanguageOption, + readyProviderIds: AiTranslationProviderId[], + engineDisplayNames: Record, +): string { + const supportingReadyProviders = option.providerIds.filter((id) => + readyProviderIds.includes(id), + ); + if ( + supportingReadyProviders.length === 0 || + supportingReadyProviders.length === readyProviderIds.length + ) { + return ""; + } + return supportingReadyProviders + .map((id) => engineDisplayNames[id]) + .join(", "); +} + +function getSupportedLanguagesConfigKey( + settings: IAiTranslationSettings | undefined, +): string { + return JSON.stringify( + aiTranslationEngineSpecs.map((spec) => ({ + providerId: spec.providerId, + ready: isEngineReady(settings, spec), + credentials: spec.credentialPaths.map( + (path) => settings?.[path] ?? "", + ), + })), + ); +} + +// Debounces live validation for a single engine: whenever its enabled state, credentials, or the +// shared target language change (and it has everything it needs), waits ~600ms and then posts +// settings/validateAiTranslationEngine for just that engine. +function useAiTranslationEngineValidation( + spec: IEngineFieldSpec, + settings: IAiTranslationSettings | undefined, + initialValidation: IAiTranslationEngineValidation | undefined, +): { + validation: IAiTranslationEngineValidation | undefined; + isPending: boolean; +} { + const [validation, setValidation] = React.useState< + IAiTranslationEngineValidation | undefined + >(initialValidation?.upToDate ? initialValidation : undefined); + const [isPending, setIsPending] = React.useState(false); + const lastProbeKeyRef = React.useRef(""); + const latestSettingsRef = React.useRef(settings); + latestSettingsRef.current = settings; + + React.useEffect(() => { + setValidation( + initialValidation?.upToDate ? initialValidation : undefined, + ); + const loadedProbeKey = getEngineProbeKey( + latestSettingsRef.current, + spec, + ); + if (initialValidation?.upToDate && initialValidation.message) { + lastProbeKeyRef.current = loadedProbeKey; + } else { + lastProbeKeyRef.current = ""; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValidation]); + + React.useEffect(() => { + if (!isEngineEnabled(settings, spec)) { + setIsPending(false); + return; + } + + const probeKey = getEngineProbeKey(settings, spec); + if (probeKey === lastProbeKeyRef.current) { + return; + } + + lastProbeKeyRef.current = probeKey; + + if ( + !hasEngineCredentials(settings, spec) || + !settings?.aiTranslationTargetLanguageTag?.trim() + ) { + setIsPending(false); + setValidation(undefined); + return; + } + + setIsPending(true); + setValidation(undefined); + + let cancelled = false; + const timeoutId = window.setTimeout(() => { + void (async () => { + try { + const response = await postJsonAsync( + "settings/validateAiTranslationEngine", + { providerId: spec.providerId }, + ); + if (cancelled) { + return; + } + setValidation( + parseAiTranslationEngineValidation(response?.data), + ); + } finally { + if (!cancelled) { + setIsPending(false); + } + } + })(); + }, 600); + + return () => { + cancelled = true; + window.clearTimeout(timeoutId); + }; + }, [settings, spec]); + + return { validation, isPending }; +} + +function getEngineValidationDisplay( + isPending: boolean, + validation: IAiTranslationEngineValidation | undefined, +): { text: string; color: string } { + if (isPending) { + return { text: "Testing translation...", color: "#555" }; + } + + if (!validation?.message) { + return { text: "", color: "#555" }; + } + + return { + text: validation.succeeded + ? `"Today a reader, tomorrow a leader." --> ${validation.message}` + : `Translation test failed: ${validation.message}`, + color: validation.succeeded ? "#2e7d32" : "#b3261e", + }; +} + +// Shared by all three engines: reads its text/color/testId from overrideValue rather than the +// usual value/onChange, since this row is purely a read-only status display. +const EngineValidationStatusControl: React.FunctionComponent<{ + value: { text: string; color: string; testId: string }; +}> = (controlProps) => { + const display = controlProps.value; + return ( +
    + {display.text} +
    + ); +}; + +export const useAiTranslationSettingsGroup = (props: { + settings: IAiTranslationSettings | undefined; + initialValidations?: Partial< + Record + >; + groupLabel: string; + targetLanguageLabel: string; + deepLEnabledLabel: string; + deepLApiKeyLabel: string; + googleEnabledLabel: string; + googleServiceAccountEmailLabel: string; + googlePrivateKeyLabel: string; + alpha2EnabledLabel: string; + alpha2ApiKeyLabel: string; + translationTestLabel: string; +}): React.ReactElement => { + const deepLValidation = useAiTranslationEngineValidation( + deepLFieldSpec, + props.settings, + props.initialValidations?.deepl, + ); + const googleValidation = useAiTranslationEngineValidation( + googleFieldSpec, + props.settings, + props.initialValidations?.google, + ); + const alpha2Validation = useAiTranslationEngineValidation( + alpha2FieldSpec, + props.settings, + props.initialValidations?.alpha2, + ); + + const [supportedTargetLanguages, setSupportedTargetLanguages] = + React.useState([]); + const [supportedLanguagesMessage, setSupportedLanguagesMessage] = + React.useState(""); + const [isLoadingSupportedLanguages, setIsLoadingSupportedLanguages] = + React.useState(false); + const [languageOptionsVersion, setLanguageOptionsVersion] = + React.useState(0); + const lastSupportedLanguagesConfigKeyRef = React.useRef(""); + + const loadSupportedLanguages = React.useCallback(async () => { + const languageConfigKey = getSupportedLanguagesConfigKey( + props.settings, + ); + if (getReadyProviderIds(props.settings).length === 0) { + setSupportedTargetLanguages([]); + setSupportedLanguagesMessage(""); + lastSupportedLanguagesConfigKeyRef.current = languageConfigKey; + return; + } + + if ( + languageConfigKey === lastSupportedLanguagesConfigKeyRef.current && + supportedTargetLanguages.length > 0 + ) { + return; + } + + setIsLoadingSupportedLanguages(true); + setSupportedLanguagesMessage(""); + try { + const response = await postJsonAsync( + "settings/aiTranslationSupportedLanguages", + props.settings, + ); + const data = response?.data as + | IAiTranslationSupportedLanguagesResponse + | undefined; + const languages = parseSupportedTargetLanguageOptions(data); + setSupportedTargetLanguages(languages); + setSupportedLanguagesMessage(data?.message ?? ""); + lastSupportedLanguagesConfigKeyRef.current = languageConfigKey; + setLanguageOptionsVersion((value) => value + 1); + } finally { + setIsLoadingSupportedLanguages(false); + } + }, [props.settings, supportedTargetLanguages.length]); + + React.useEffect(() => { + const currentLanguageConfigKey = getSupportedLanguagesConfigKey( + props.settings, + ); + if ( + currentLanguageConfigKey !== + lastSupportedLanguagesConfigKeyRef.current + ) { + setSupportedTargetLanguages([]); + setSupportedLanguagesMessage(""); + } + }, [props.settings]); + + // Provider-backed target-language lists depend on external credentials and should be ready + // as soon as any engine's config becomes usable. + React.useEffect(() => { + if (getReadyProviderIds(props.settings).length === 0) { + return; + } + + void loadSupportedLanguages(); + }, [loadSupportedLanguages, props.settings]); + + const readyProviderIds = getReadyProviderIds(props.settings); + const usesEngineManagedTargetLanguages = readyProviderIds.length > 0; + + const engineDisplayNames: Record = { + deepl: props.deepLEnabledLabel, + google: props.googleEnabledLabel, + alpha2: props.alpha2EnabledLabel, + }; + + const AiTranslationTargetLanguageControl: React.FunctionComponent<{ + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }> = (controlProps) => { + if (!usesEngineManagedTargetLanguages) { + return ( + { + controlProps.onChange(event.target.value); + }} + inputProps={{ + "data-testid": "ai-translation-target-language-input", + }} + /> + ); + } + + const currentValue = controlProps.value || ""; + const knownOptions = supportedTargetLanguages.some( + (option) => option.value === currentValue, + ) + ? supportedTargetLanguages + : currentValue + ? [ + ...supportedTargetLanguages, + { + value: currentValue, + label: currentValue, + providerIds: [], + }, + ] + : supportedTargetLanguages; + + return ( + // Constrain the width so a long error message wraps within the dialog instead of + // forcing it to scroll horizontally (the message is rendered below, not as the + // TextField's helperText, so we can style it as a red, wrapping error). +
    + { + controlProps.onChange(event.target.value); + }} + SelectProps={{ + onOpen: () => { + void loadSupportedLanguages(); + }, + }} + inputProps={{ + "data-testid": "ai-translation-target-language-select", + "data-language-options-version": languageOptionsVersion, + }} + > + + {isLoadingSupportedLanguages && ( + + Loading languages... + + )} + {knownOptions.map((option) => { + const note = getLanguageSupportNote( + option, + readyProviderIds, + engineDisplayNames, + ); + return ( + + {option.label} + {note && ( + + ({note}) + + )} + + ); + })} + + {supportedLanguagesMessage && ( +
    + {supportedLanguagesMessage} +
    + )} +
    + ); + }; + + return ( + + + {props.settings?.aiTranslationDeepLEnabled && ( + <> + + + path="aiTranslationDeepLValidationDisplay" + control={EngineValidationStatusControl} + label={props.translationTestLabel} + overrideValue={{ + ...getEngineValidationDisplay( + deepLValidation.isPending, + deepLValidation.validation, + ), + testId: "ai-translation-deepl-validation-status", + }} + /> + + )} + + {props.settings?.aiTranslationGoogleEnabled && ( + <> + + + + path="aiTranslationGoogleValidationDisplay" + control={EngineValidationStatusControl} + label={props.translationTestLabel} + overrideValue={{ + ...getEngineValidationDisplay( + googleValidation.isPending, + googleValidation.validation, + ), + testId: "ai-translation-google-validation-status", + }} + /> + + )} + + {props.settings?.aiTranslationAlpha2Enabled && ( + <> + + + path="aiTranslationAlpha2ValidationDisplay" + control={EngineValidationStatusControl} + label={props.translationTestLabel} + overrideValue={{ + ...getEngineValidationDisplay( + alpha2Validation.isPending, + alpha2Validation.validation, + ), + testId: "ai-translation-alpha2-validation-status", + }} + /> + + )} + + path="aiTranslationTargetLanguageTag" + control={AiTranslationTargetLanguageControl} + label={props.targetLanguageLabel} + /> + + ); +}; diff --git a/src/BloomBrowserUI/collectionsTab/BookButton.tsx b/src/BloomBrowserUI/collectionsTab/BookButton.tsx index 023c71218ce3..308a113e1fd8 100644 --- a/src/BloomBrowserUI/collectionsTab/BookButton.tsx +++ b/src/BloomBrowserUI/collectionsTab/BookButton.tsx @@ -209,6 +209,15 @@ export const BookButton: React.FunctionComponent<{ requiresSavePermission: true, // marginal, but it does change the content of the book folder hide: () => !props.collection.isEditableCollection, }, + { + label: "Remove AI Source Translations", + l10nId: "CollectionTab.BookMenu.RemoveAiSourceTranslations", + command: "bookCommand/removeAiSourceTranslations", + requiresSavePermission: true, // changes the content of the book + // Deliberately NOT gated by subscription/feature: a user whose subscription no longer + // includes AI Source Bubbles must still be able to strip AI content out of a book. + hide: () => !props.collection.isEditableCollection, + }, ]; const editableCollectionName = useApiString( diff --git a/src/BloomBrowserUI/react_components/Progress/ProgressDialog.tsx b/src/BloomBrowserUI/react_components/Progress/ProgressDialog.tsx index 3c45842cfdaa..5c2e85cdca88 100644 --- a/src/BloomBrowserUI/react_components/Progress/ProgressDialog.tsx +++ b/src/BloomBrowserUI/react_components/Progress/ProgressDialog.tsx @@ -22,6 +22,7 @@ import { import { DialogCancelButton, DialogCloseButton, + DialogOkButton, } from "../BloomDialog/commonDialogComponents"; import { WireUpForWinforms } from "../../utils/WireUpWinform"; import { @@ -37,6 +38,9 @@ export interface IProgressDialogProps { // defaults to "never" showReportButton?: "always" | "if-error" | "never"; showCancelButton?: boolean; + // When the work finishes, dismiss with an "OK" button instead of the default "Close" button. + // (The Cancel button, if any, is only shown while the work is in progress either way.) + showOkButtonWhenDone?: boolean; open: boolean; // Controls whether or not the dialog is open (visible). (Theoretically, initial value should match dialogEnvironment.initiallyOpen, but not strictly necessary) onClose: () => void; // Callback fired when the component requests to be closed. @@ -199,6 +203,31 @@ export const ProgressDialog: React.FunctionComponent = ( titleColor = "white"; } + // The primary button shown once the work has finished (while it is still running we instead + // show Cancel, or a hidden placeholder -- see the bottom buttons below). A fatal error forces a + // Quit button; otherwise the caller chooses OK (showOkButtonWhenDone) or the default Close. + let donePrimaryButton: JSX.Element; + if (sawFatalError) { + donePrimaryButton = ( + + Quit + + ); + } else if (props.showOkButtonWhenDone) { + donePrimaryButton = ( + + ); + } else { + donePrimaryButton = ; + } + return ( = ( )} - {sawFatalError ? ( - - Quit - - ) : ( - - )} + {donePrimaryButton} ) : // if we're not done, show the cancel button if that was called for... props.showCancelButton ? ( @@ -420,6 +436,7 @@ interface IEmbeddedProgressDialogConfig { // defaults means "never" showReportButton?: "always" | "if-error" | "never"; showCancelButton?: boolean; + showOkButtonWhenDone?: boolean; } // Simply render one of these, with no props, at the top level of any document where the diff --git a/src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs b/src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs deleted file mode 100644 index 86ba32010ac6..000000000000 --- a/src/BloomExe/AiSourceBubbles/AiSourceBubblesService.cs +++ /dev/null @@ -1,851 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using Bloom.Collection; -using Bloom.SubscriptionAndFeatures; -using Bloom.Utils; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using SIL.WritingSystems; - -namespace Bloom.AiSourceBubbles -{ - /// - /// Request payload for translating a single source bubble. - /// - public class AiSourceBubblesTranslateRequest - { - public string SourceText { get; set; } - public string SourceLanguageTag { get; set; } - } - - /// - /// Response payload for translating a single source bubble. - /// - public class AiSourceBubblesTranslateResponse - { - public string ProviderId { get; set; } - public string TargetLanguageTag { get; set; } - public string AiLanguageTag { get; set; } - public string Text { get; set; } - } - - /// - /// Result of validating the current AI Source Bubbles configuration. - /// - public class AiSourceBubblesValidationResult - { - public bool Succeeded { get; set; } - public string ConfigurationFingerprint { get; set; } - public string Message { get; set; } - } - - /// - /// Option surfaced to the Collection Settings target-language picker. - /// - public class AiSourceBubblesTargetLanguageOption - { - [JsonProperty("value")] - public string Value { get; set; } - - [JsonProperty("label")] - public string Label { get; set; } - } - - /// - /// Coordinates collection-backed AI Source Bubbles translation. - /// - public class AiSourceBubblesService - { - public const string kValidationProbeText = "Today a reader, tomorrow a leader."; - public const string kValidationProbeSourceLanguageTag = "en"; - - private static readonly HttpClient _httpClient = new HttpClient(); - private readonly CollectionSettings _collectionSettings; - private readonly Dictionary _providers; - - public AiSourceBubblesService(CollectionSettings collectionSettings) - : this(collectionSettings, null) { } - - internal AiSourceBubblesService( - CollectionSettings collectionSettings, - Dictionary providers - ) - { - _collectionSettings = collectionSettings; - _providers = - providers - ?? new Dictionary( - StringComparer.OrdinalIgnoreCase - ) - { - { "deepl", new DeepLAiSourceBubblesTranslationProvider() }, - { "google", new GoogleAiSourceBubblesTranslationProvider() }, - }; - } - - /// - /// Translates one source-bubble text block using the provider configured on the current collection. - /// - public async Task TranslateAsync( - AiSourceBubblesTranslateRequest request - ) - { - return await TranslateAsync(request, true); - } - - /// - /// Validates the configured provider, credentials, and target language with a probe translation. - /// - public async Task ValidateConfigurationAsync() - { - var response = await TranslateAsync( - new AiSourceBubblesTranslateRequest - { - SourceText = kValidationProbeText, - SourceLanguageTag = kValidationProbeSourceLanguageTag, - }, - false - ); - - return new AiSourceBubblesValidationResult - { - Succeeded = true, - ConfigurationFingerprint = GetConfigurationFingerprint(_collectionSettings), - Message = response.Text, - }; - } - - /// - /// Gets the target languages currently supported by the configured provider. - /// - public async Task< - List - > GetSupportedTargetLanguagesAsync() - { - var provider = GetSelectedProvider(); - return await provider.GetSupportedTargetLanguagesAsync( - _collectionSettings, - _httpClient - ); - } - - private async Task TranslateAsync( - AiSourceBubblesTranslateRequest request, - bool requireFeatureEnabled - ) - { - if (request == null) - throw new ArgumentNullException(nameof(request)); - if (string.IsNullOrWhiteSpace(request.SourceText)) - throw new ArgumentException("Source text is required.", nameof(request)); - - if (requireFeatureEnabled) - { - var featureStatus = FeatureStatus.GetFeatureStatus( - _collectionSettings.Subscription, - FeatureName.AiSourceBubbles - ); - if (!featureStatus.Visible || !featureStatus.Enabled) - { - throw new InvalidOperationException( - "AI Source Bubbles is not enabled for this collection." - ); - } - } - - var provider = GetSelectedProvider(); - var targetLanguageTag = NormalizeBloomLanguageTag( - _collectionSettings.AiSourceBubblesTargetLanguageTag - ); - if (string.IsNullOrWhiteSpace(targetLanguageTag)) - { - throw new InvalidOperationException( - "Set a target language tag in Collection Settings > AI Source Bubbles." - ); - } - - var startedAt = DateTimeOffset.Now; - var stopwatch = Stopwatch.StartNew(); - WriteTranslationActivity( - "request", - startedAt, - provider.ProviderId, - request.SourceLanguageTag, - targetLanguageTag, - request.SourceText - ); - - try - { - var translatedText = await provider.TranslateAsync( - _collectionSettings, - request.SourceText, - request.SourceLanguageTag, - targetLanguageTag, - _httpClient - ); - stopwatch.Stop(); - WriteTranslationActivity( - "response", - DateTimeOffset.Now, - provider.ProviderId, - request.SourceLanguageTag, - targetLanguageTag, - request.SourceText, - translatedText, - stopwatch.Elapsed - ); - - return new AiSourceBubblesTranslateResponse - { - ProviderId = provider.ProviderId, - TargetLanguageTag = targetLanguageTag, - AiLanguageTag = GetAiLanguageTag(targetLanguageTag, provider.ProviderId), - Text = translatedText, - }; - } - catch (Exception exception) - { - stopwatch.Stop(); - Console.WriteLine( - $"[AiSourceBubbles][response] time={DateTimeOffset.Now:O} provider={provider.ProviderId} sourceLanguage={request.SourceLanguageTag} targetLanguage={targetLanguageTag} elapsedMs={stopwatch.ElapsedMilliseconds} input={JsonConvert.ToString(request.SourceText)} error={JsonConvert.ToString(exception.Message)}" - ); - throw; - } - } - - private static void WriteTranslationActivity( - string stage, - DateTimeOffset time, - string providerId, - string sourceLanguageTag, - string targetLanguageTag, - string sourceText, - string translatedText = null, - TimeSpan? elapsed = null - ) - { - var logLine = - $"[AiSourceBubbles][{stage}] time={time:O} provider={providerId} sourceLanguage={sourceLanguageTag} targetLanguage={targetLanguageTag} input={JsonConvert.ToString(sourceText)}"; - - if (translatedText != null) - { - logLine += $" output={JsonConvert.ToString(translatedText)}"; - } - - if (elapsed.HasValue) - { - logLine += $" elapsedMs={elapsed.Value.TotalMilliseconds:F0}"; - } - - Console.WriteLine(logLine); - } - - /// - /// Builds the Bloom language tag used for AI content in a translation group. - /// - public static string GetAiLanguageTag(string targetLanguageTag, string providerId) - { - var normalizedTarget = NormalizeBloomLanguageTag(targetLanguageTag); - var normalizedProvider = NormalizeProviderId(providerId); - if ( - string.IsNullOrWhiteSpace(normalizedTarget) - || string.IsNullOrWhiteSpace(normalizedProvider) - ) - { - return string.Empty; - } - - return $"{normalizedTarget}-x-ai-{normalizedProvider}"; - } - - /// - /// Normalizes the provider id used in settings, API payloads, and AI language tags. - /// - public static string NormalizeProviderId(string providerId) - { - if (string.IsNullOrWhiteSpace(providerId)) - return string.Empty; - - var normalized = providerId.Trim().ToLowerInvariant(); - return normalized switch - { - "alpha-2" => string.Empty, - "alpha2" => string.Empty, - "googletranslate" => "google", - _ => normalized, - }; - } - - /// - /// Normalizes a Bloom language tag for provider requests while preserving region when available. - /// - public static string NormalizeBloomLanguageTag(string languageTag) - { - if (string.IsNullOrWhiteSpace(languageTag)) - return string.Empty; - - var trimmed = languageTag.Trim(); - var privateUseIndex = trimmed.IndexOf("-x-", StringComparison.OrdinalIgnoreCase); - if (privateUseIndex >= 0) - { - trimmed = trimmed.Substring(0, privateUseIndex); - } - - trimmed = MiscUtils.NormalizeLanguageTagCapitalization(trimmed); - if ( - !IetfLanguageTag.TryGetParts( - trimmed, - out var language, - out var script, - out var region, - out var variant - ) || string.IsNullOrWhiteSpace(language) - ) - { - return trimmed; - } - - language = language.ToLowerInvariant(); - if (!string.IsNullOrWhiteSpace(region)) - { - return $"{language}-{region.ToUpperInvariant()}"; - } - - return language; - } - - /// - /// Extracts the Google Cloud project id from a service account email. - /// - public static string GetGoogleProjectIdFromServiceAccountEmail(string serviceAccountEmail) - { - if (string.IsNullOrWhiteSpace(serviceAccountEmail)) - { - throw new InvalidOperationException( - "Set a Google service account email in Collection Settings > AI Source Bubbles." - ); - } - - var trimmedEmail = serviceAccountEmail.Trim(); - var atIndex = trimmedEmail.IndexOf('@'); - if (atIndex < 0 || atIndex == trimmedEmail.Length - 1) - { - throw new InvalidOperationException( - "Google service account email is not in the expected format." - ); - } - - var domain = trimmedEmail.Substring(atIndex + 1); - const string kExpectedSuffix = ".iam.gserviceaccount.com"; - if (!domain.EndsWith(kExpectedSuffix, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - "Google service account email must end with .iam.gserviceaccount.com." - ); - } - - var projectId = domain.Substring(0, domain.Length - kExpectedSuffix.Length); - if (string.IsNullOrWhiteSpace(projectId)) - { - throw new InvalidOperationException( - "Google service account email does not contain a project id." - ); - } - - return projectId; - } - - /// - /// Builds a stable fingerprint of the AI Source Bubbles configuration without storing raw secrets. - /// - public static string GetConfigurationFingerprint(CollectionSettings collectionSettings) - { - if (collectionSettings == null) - throw new ArgumentNullException(nameof(collectionSettings)); - - return GetConfigurationFingerprint( - collectionSettings.AiSourceBubblesProviderId, - collectionSettings.AiSourceBubblesTargetLanguageTag, - collectionSettings.AiSourceBubblesDeepLApiKey, - collectionSettings.AiSourceBubblesGoogleServiceAccountEmail, - collectionSettings.AiSourceBubblesGooglePrivateKey - ); - } - - /// - /// Builds a stable fingerprint of provider, target language, and provider-specific credentials. - /// - public static string GetConfigurationFingerprint( - string providerId, - string targetLanguageTag, - string deepLApiKey, - string googleServiceAccountEmail, - string googlePrivateKey - ) - { - var normalizedProvider = NormalizeProviderId(providerId); - var normalizedTargetLanguageTag = NormalizeBloomLanguageTag(targetLanguageTag); - var credentialKey = normalizedProvider switch - { - "google" => - $"{googleServiceAccountEmail?.Trim()}\n{AiSourceBubblesProviderHelpers.NormalizeGooglePrivateKey(googlePrivateKey)}", - _ => deepLApiKey?.Trim() ?? string.Empty, - }; - var fingerprintInput = - $"{normalizedProvider}\n{normalizedTargetLanguageTag}\n{credentialKey}"; - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(fingerprintInput))); - } - - private IAiSourceBubblesTranslationProvider GetSelectedProvider() - { - var providerId = NormalizeProviderId(_collectionSettings.AiSourceBubblesProviderId); - if (string.IsNullOrWhiteSpace(providerId)) - { - throw new InvalidOperationException( - "Select an AI Source Bubbles provider in Collection Settings." - ); - } - - if (!_providers.TryGetValue(providerId, out var provider)) - { - throw new InvalidOperationException($"Unsupported AI provider '{providerId}'."); - } - - return provider; - } - } - - internal interface IAiSourceBubblesTranslationProvider - { - string ProviderId { get; } - - Task> GetSupportedTargetLanguagesAsync( - CollectionSettings collectionSettings, - HttpClient httpClient - ); - - Task TranslateAsync( - CollectionSettings collectionSettings, - string sourceText, - string sourceLanguageTag, - string targetLanguageTag, - HttpClient httpClient - ); - } - - internal sealed class DeepLAiSourceBubblesTranslationProvider - : IAiSourceBubblesTranslationProvider - { - public string ProviderId => "deepl"; - - public async Task< - List - > GetSupportedTargetLanguagesAsync( - CollectionSettings collectionSettings, - HttpClient httpClient - ) - { - if (string.IsNullOrWhiteSpace(collectionSettings.AiSourceBubblesDeepLApiKey)) - { - throw new InvalidOperationException( - "Set a DeepL API key in Collection Settings > AI Source Bubbles." - ); - } - - using var request = new HttpRequestMessage( - HttpMethod.Get, - GetApiBaseUrl(collectionSettings.AiSourceBubblesDeepLApiKey) - + "/v2/languages?type=target" - ); - request.Headers.Authorization = new AuthenticationHeaderValue( - "DeepL-Auth-Key", - collectionSettings.AiSourceBubblesDeepLApiKey.Trim() - ); - - using var response = await httpClient.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); - AiSourceBubblesProviderHelpers.EnsureSuccess(response, responseContent, "DeepL"); - - var languages = JArray.Parse(responseContent); - var options = new List(); - foreach (var languageToken in languages) - { - var languageCode = languageToken["language"]?.Value(); - if (string.IsNullOrWhiteSpace(languageCode)) - { - continue; - } - - var normalizedLanguageCode = AiSourceBubblesService.NormalizeBloomLanguageTag( - languageCode - ); - var name = languageToken["name"]?.Value() ?? normalizedLanguageCode; - options.Add( - new AiSourceBubblesTargetLanguageOption - { - Value = normalizedLanguageCode, - Label = name, - } - ); - } - - options.Sort( - (first, second) => - StringComparer.CurrentCultureIgnoreCase.Compare(first.Label, second.Label) - ); - return options; - } - - public async Task TranslateAsync( - CollectionSettings collectionSettings, - string sourceText, - string sourceLanguageTag, - string targetLanguageTag, - HttpClient httpClient - ) - { - if (string.IsNullOrWhiteSpace(collectionSettings.AiSourceBubblesDeepLApiKey)) - { - throw new InvalidOperationException( - "Set a DeepL API key in Collection Settings > AI Source Bubbles." - ); - } - - var requestBody = new - { - text = new[] { sourceText }, - source_lang = NormalizeDeepLLanguageTag(sourceLanguageTag), - target_lang = NormalizeDeepLLanguageTag(targetLanguageTag), - }; - - using var request = new HttpRequestMessage( - HttpMethod.Post, - GetEndpoint(collectionSettings.AiSourceBubblesDeepLApiKey) - ); - request.Content = new StringContent( - JsonConvert.SerializeObject(requestBody), - Encoding.UTF8, - "application/json" - ); - request.Headers.Authorization = new AuthenticationHeaderValue( - "DeepL-Auth-Key", - collectionSettings.AiSourceBubblesDeepLApiKey.Trim() - ); - - using var response = await httpClient.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); - AiSourceBubblesProviderHelpers.EnsureSuccess(response, responseContent, "DeepL"); - - var responseJson = JObject.Parse(responseContent); - var translatedText = responseJson["translations"]?[0]?["text"]?.Value(); - if (string.IsNullOrWhiteSpace(translatedText)) - { - throw new InvalidOperationException("DeepL returned no translated text."); - } - - return translatedText; - } - - private static string GetEndpoint(string apiKey) - { - return GetApiBaseUrl(apiKey) + "/v2/translate"; - } - - private static string GetApiBaseUrl(string apiKey) - { - return apiKey.Trim().EndsWith(":fx", StringComparison.OrdinalIgnoreCase) - ? "https://api-free.deepl.com" - : "https://api.deepl.com"; - } - - private static string NormalizeDeepLLanguageTag(string languageTag) - { - return AiSourceBubblesService - .NormalizeBloomLanguageTag(languageTag) - .Replace('_', '-') - .ToUpperInvariant(); - } - } - - internal sealed class GoogleAiSourceBubblesTranslationProvider - : IAiSourceBubblesTranslationProvider - { - private const string kScope = "https://www.googleapis.com/auth/cloud-translation"; - private const string kTokenEndpoint = "https://oauth2.googleapis.com/token"; - private const string kTranslateEndpoint = - "https://translation.googleapis.com/language/translate/v2"; - private const string kSupportedLanguagesEndpointTemplate = - "https://translation.googleapis.com/v3/projects/{0}/locations/global/supportedLanguages?display_language_code=en"; - - public string ProviderId => "google"; - - public async Task< - List - > GetSupportedTargetLanguagesAsync( - CollectionSettings collectionSettings, - HttpClient httpClient - ) - { - EnsureGoogleCredentials(collectionSettings); - - var accessToken = await GetAccessTokenAsync(collectionSettings, httpClient); - var projectId = AiSourceBubblesService.GetGoogleProjectIdFromServiceAccountEmail( - collectionSettings.AiSourceBubblesGoogleServiceAccountEmail - ); - using var request = new HttpRequestMessage( - HttpMethod.Get, - string.Format(kSupportedLanguagesEndpointTemplate, Uri.EscapeDataString(projectId)) - ); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - - using var response = await httpClient.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); - AiSourceBubblesProviderHelpers.EnsureSuccess( - response, - responseContent, - "Google Translate" - ); - - var responseJson = JObject.Parse(responseContent); - var languages = responseJson["languages"] as JArray; - var options = new List(); - if (languages == null) - { - return options; - } - - foreach (var languageToken in languages) - { - var supportsTarget = languageToken["supportTarget"]?.Value() ?? false; - if (!supportsTarget) - { - continue; - } - - var languageCode = languageToken["languageCode"]?.Value(); - if (string.IsNullOrWhiteSpace(languageCode)) - { - continue; - } - - var normalizedLanguageCode = AiSourceBubblesService.NormalizeBloomLanguageTag( - languageCode - ); - var displayName = - languageToken["displayName"]?.Value() ?? normalizedLanguageCode; - options.Add( - new AiSourceBubblesTargetLanguageOption - { - Value = normalizedLanguageCode, - Label = displayName, - } - ); - } - - options.Sort( - (first, second) => - StringComparer.CurrentCultureIgnoreCase.Compare(first.Label, second.Label) - ); - return options; - } - - public async Task TranslateAsync( - CollectionSettings collectionSettings, - string sourceText, - string sourceLanguageTag, - string targetLanguageTag, - HttpClient httpClient - ) - { - EnsureGoogleCredentials(collectionSettings); - - var accessToken = await GetAccessTokenAsync(collectionSettings, httpClient); - var fields = new List> - { - new KeyValuePair("q", sourceText), - new KeyValuePair("target", targetLanguageTag), - new KeyValuePair("format", "text"), - }; - var normalizedSourceLanguage = AiSourceBubblesService.NormalizeBloomLanguageTag( - sourceLanguageTag - ); - if (!string.IsNullOrWhiteSpace(normalizedSourceLanguage)) - { - fields.Add(new KeyValuePair("source", normalizedSourceLanguage)); - } - - using var request = new HttpRequestMessage(HttpMethod.Post, kTranslateEndpoint) - { - Content = new FormUrlEncodedContent(fields), - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - - using var response = await httpClient.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); - AiSourceBubblesProviderHelpers.EnsureSuccess( - response, - responseContent, - "Google Translate" - ); - - var responseJson = JObject.Parse(responseContent); - var translatedText = responseJson["data"] - ?["translations"]?[0]?["translatedText"]?.Value(); - if (string.IsNullOrWhiteSpace(translatedText)) - { - throw new InvalidOperationException( - "Google Translate returned no translated text." - ); - } - - return WebUtility.HtmlDecode(translatedText); - } - - private static void EnsureGoogleCredentials(CollectionSettings collectionSettings) - { - if ( - string.IsNullOrWhiteSpace( - collectionSettings.AiSourceBubblesGoogleServiceAccountEmail - ) - ) - { - throw new InvalidOperationException( - "Set a Google service account email in Collection Settings > AI Source Bubbles." - ); - } - if (string.IsNullOrWhiteSpace(collectionSettings.AiSourceBubblesGooglePrivateKey)) - { - throw new InvalidOperationException( - "Set a Google service account private key in Collection Settings > AI Source Bubbles." - ); - } - } - - private static async Task GetAccessTokenAsync( - CollectionSettings collectionSettings, - HttpClient httpClient - ) - { - var now = DateTimeOffset.UtcNow; - var jwtHeader = AiSourceBubblesProviderHelpers.Base64UrlEncode( - Encoding.UTF8.GetBytes( - JsonConvert.SerializeObject(new { alg = "RS256", typ = "JWT" }) - ) - ); - var jwtPayload = AiSourceBubblesProviderHelpers.Base64UrlEncode( - Encoding.UTF8.GetBytes( - JsonConvert.SerializeObject( - new - { - iss = collectionSettings.AiSourceBubblesGoogleServiceAccountEmail, - scope = kScope, - aud = kTokenEndpoint, - iat = now.ToUnixTimeSeconds(), - exp = now.AddMinutes(59).ToUnixTimeSeconds(), - } - ) - ) - ); - var signingInput = $"{jwtHeader}.{jwtPayload}"; - var signedJwt = - $"{signingInput}.{SignJwt(signingInput, collectionSettings.AiSourceBubblesGooglePrivateKey)}"; - - using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, kTokenEndpoint) - { - Content = new FormUrlEncodedContent( - new[] - { - new KeyValuePair( - "grant_type", - "urn:ietf:params:oauth:grant-type:jwt-bearer" - ), - new KeyValuePair("assertion", signedJwt), - } - ), - }; - - using var tokenResponse = await httpClient.SendAsync(tokenRequest); - var tokenContent = await tokenResponse.Content.ReadAsStringAsync(); - AiSourceBubblesProviderHelpers.EnsureSuccess( - tokenResponse, - tokenContent, - "Google OAuth" - ); - - var tokenJson = JObject.Parse(tokenContent); - var accessToken = tokenJson["access_token"]?.Value(); - if (string.IsNullOrWhiteSpace(accessToken)) - { - throw new InvalidOperationException("Google OAuth returned no access token."); - } - - return accessToken; - } - - private static string SignJwt(string signingInput, string privateKey) - { - using var rsa = RSA.Create(); - rsa.ImportFromPem( - AiSourceBubblesProviderHelpers.NormalizeGooglePrivateKey(privateKey).ToCharArray() - ); - var signature = rsa.SignData( - Encoding.UTF8.GetBytes(signingInput), - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1 - ); - return AiSourceBubblesProviderHelpers.Base64UrlEncode(signature); - } - } - - internal static class AiSourceBubblesProviderHelpers - { - internal static string NormalizeGooglePrivateKey(string privateKey) - { - return (privateKey ?? string.Empty).Replace("\\r", "").Replace("\\n", "\n").Trim(); - } - - internal static void EnsureSuccess( - HttpResponseMessage response, - string responseContent, - string providerName - ) - { - if (response.IsSuccessStatusCode) - return; - - var providerMessage = responseContent; - try - { - var json = JObject.Parse(responseContent); - providerMessage = - json["message"]?.Value() - ?? json["error"]?.Value() - ?? json["error"]?["message"]?.Value() - ?? json["detail"]?[0]?["msg"]?.Value() - ?? responseContent; - } - catch - { - // Keep the original response text when it isn't JSON. - } - - throw new InvalidOperationException( - $"{providerName} request failed: {(int)response.StatusCode} {response.ReasonPhrase}. {providerMessage}".Trim() - ); - } - - internal static string Base64UrlEncode(byte[] bytes) - { - return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - } - } -} diff --git a/src/BloomExe/AiTranslation/AiTranslationBookScanner.cs b/src/BloomExe/AiTranslation/AiTranslationBookScanner.cs new file mode 100644 index 000000000000..fab45d8141f1 --- /dev/null +++ b/src/BloomExe/AiTranslation/AiTranslationBookScanner.cs @@ -0,0 +1,500 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Bloom.Book; +using Bloom.Collection; +using Bloom.SafeXml; + +namespace Bloom.AiTranslation +{ + /// + /// One eligible translation group found by AiTranslationBookScanner.Scan(): the chosen + /// source text to translate, and whether a current (up to date) translation already exists + /// per engine. + /// + public class AiTranslationGroupInfo + { + /// The bloom-translationGroup element this info describes. + public SafeXmlElement GroupElement { get; } + + /// The language tag of the bloom-editable chosen as the translation source. + public string SourceLanguageTag { get; } + + /// The trimmed text of the chosen source bloom-editable. + public string SourceText { get; } + + /// True if this is the book's data-book="bookTitle" translation group. + public bool IsBookTitle { get; } + + /// + /// Creates group info for one eligible translation group. See + /// AiTranslationBookScanner.Scan() for how the source language/text and IsBookTitle are + /// determined. + /// + public AiTranslationGroupInfo( + SafeXmlElement groupElement, + string sourceLanguageTag, + string sourceText, + bool isBookTitle + ) + { + GroupElement = groupElement; + SourceLanguageTag = sourceLanguageTag; + SourceText = sourceText; + IsBookTitle = isBookTitle; + } + + /// + /// The fingerprint a translation div for the given engine must currently have, based on + /// this group's current source language/text and the engine's AI language tag. + /// + public string GetExpectedFingerprint( + AiTranslationEngineSettings engine, + string targetLanguageTag + ) + { + var aiTag = AiTranslationService.GetAiLanguageTag(targetLanguageTag, engine.ProviderId); + return AiTranslationBookScanner.ComputeFingerprint( + SourceLanguageTag, + SourceText, + aiTag + ); + } + + /// + /// True if this group already has a non-empty translation div for the given engine's AI + /// language tag whose fingerprint matches the current source (i.e. it does NOT need + /// (re)translation). + /// + public bool HasCurrentTranslation( + AiTranslationEngineSettings engine, + string targetLanguageTag + ) + { + var aiTag = AiTranslationService.GetAiLanguageTag(targetLanguageTag, engine.ProviderId); + var existing = GroupElement + .SafeSelectElements($"div[@lang='{aiTag}']") + .FirstOrDefault(); + if (existing == null) + return false; + + var text = (existing.InnerText ?? "").Trim(); + if (string.IsNullOrEmpty(text)) + return false; + + return existing.GetAttribute("data-ai-fingerprint") + == GetExpectedFingerprint(engine, targetLanguageTag); + } + } + + /// + /// The result of AiTranslationBookScanner.Scan(): every eligible translation group found, in + /// document order. + /// + public class AiTranslationBookScan + { + /// All eligible groups found, in document order. + public List Groups { get; } + + private readonly string _targetLanguageTag; + + /// Wraps the groups found by a scan, remembering the target language they were scanned for. + public AiTranslationBookScan(List groups, string targetLanguageTag) + { + Groups = groups; + _targetLanguageTag = targetLanguageTag; + } + + /// + /// The ordered subset of Groups that still need a (re)translation for the given engine: + /// those lacking a current, fingerprint-matching translation div. + /// + public List GroupsNeedingTranslation( + AiTranslationEngineSettings engine + ) + { + return Groups.Where(g => !g.HasCurrentTranslation(engine, _targetLanguageTag)).ToList(); + } + } + + /// + /// Scans a book's DOM for translation groups eligible for AI translation, and + /// applies or removes AI-generated translation divs. This class does no network calls and no + /// orchestration; it is purely the scan + DOM read/write layer that the book updater (Phase 3) + /// drives around calls to AiTranslationService.TranslateSegmentsAsync. + /// + public class AiTranslationBookScanner + { + private const string kAiLangTagFragment = "-x-ai"; + private const string kBloomEditableClass = "bloom-editable"; + private const string kAiTranslationClass = "bloom-ai-translation"; + private const string kBookTitleDataBookValue = "bookTitle"; + private const string kZeroLang = "z"; + + private readonly HtmlDom _bookDom; + private readonly string _targetLanguageTag; + private readonly List _enabledEngines; + private readonly IReadOnlyList _sourceLanguagePriorities; + + /// + /// Creates a scanner for one book's DOM. sourceLanguagePriorities is the ordered list of + /// candidate language tags to prefer when choosing which bloom-editable's text is the + /// translation source for a group (e.g. the user's last-viewed source languages, then the + /// collection's L2/L3 tags, then "en"). + /// + public AiTranslationBookScanner( + HtmlDom bookDom, + string targetLanguageTag, + IEnumerable enabledEngines, + IReadOnlyList sourceLanguagePriorities + ) + { + _bookDom = bookDom ?? throw new ArgumentNullException(nameof(bookDom)); + _targetLanguageTag = targetLanguageTag; + _enabledEngines = ( + enabledEngines ?? Enumerable.Empty() + ).ToList(); + _sourceLanguagePriorities = sourceLanguagePriorities ?? Array.Empty(); + } + + /// + /// Scans all pages' translation groups, in document order, for groups eligible for AI + /// translation (see TryBuildGroupInfo for eligibility rules), choosing a + /// source text for each from sourceLanguagePriorities. + /// + public AiTranslationBookScan Scan() + { + var groups = new List(); + foreach (var page in SafeXmlElement.GetAllDivsWithClass(_bookDom.Body, "bloom-page")) + { + foreach ( + var group in SafeXmlElement.GetAllDivsWithClass(page, "bloom-translationGroup") + ) + { + var info = TryBuildGroupInfo(group); + if (info != null) + groups.Add(info); + } + } + + return new AiTranslationBookScan(groups, _targetLanguageTag); + } + + /// + /// Writes (creating or replacing) the AI translation div for one group+engine with the + /// given translated text and a matching data-ai-fingerprint. For bookTitle groups, also + /// writes/refreshes the matching entry in #bloomDataDiv (creates nothing if the data div + /// doesn't exist; real books always have one, but we don't want to throw on a test DOM + /// that omits it). + /// + public void ApplyTranslation( + AiTranslationGroupInfo group, + AiTranslationEngineSettings engine, + string translatedText + ) + { + var aiTag = AiTranslationService.GetAiLanguageTag( + _targetLanguageTag, + engine.ProviderId + ); + var fingerprint = ComputeFingerprint(group.SourceLanguageTag, group.SourceText, aiTag); + + WriteChildDiv( + group.GroupElement, + aiTag, + translatedText, + div => + { + div.AddClass(kBloomEditableClass); + div.AddClass(kAiTranslationClass); + div.SetAttribute("data-ai-fingerprint", fingerprint); + } + ); + + if (!group.IsBookTitle) + return; + + var dataDiv = FindBloomDataDiv(); + if (dataDiv == null) + return; // real books always have one; nothing to do if a test DOM omits it. + + WriteChildDiv( + dataDiv, + aiTag, + translatedText, + div => div.SetAttribute("data-book", kBookTitleDataBookValue), + matchAttribute: "data-book", + matchValue: kBookTitleDataBookValue + ); + } + + /// + /// Removes AI translation divs that no longer belong: any div whose lang contains "-x-ai" + /// under a group that is no longer eligible for AI translation at all (orphaned), or under + /// an eligible group but for a disabled engine/inactive target language, or with a + /// fingerprint that no longer matches the group's current source text (the source was + /// edited since translation). Also cleans matching #bloomDataDiv bookTitle entries. Returns + /// the total number of divs removed. + /// + public int RemoveStaleAiDivs() + { + var activeTags = new HashSet( + _enabledEngines.Select(e => + AiTranslationService.GetAiLanguageTag(_targetLanguageTag, e.ProviderId) + ), + StringComparer.OrdinalIgnoreCase + ); + var validBookTitleLangs = new HashSet(StringComparer.OrdinalIgnoreCase); + var removedCount = 0; + + foreach (var page in SafeXmlElement.GetAllDivsWithClass(_bookDom.Body, "bloom-page")) + { + foreach ( + var group in SafeXmlElement.GetAllDivsWithClass(page, "bloom-translationGroup") + ) + { + // Rebuild eligibility from scratch (not just the eligible groups from Scan()) + // so we also catch AI divs left behind in groups that are no longer eligible. + var groupInfo = TryBuildGroupInfo(group); + foreach (var aiDiv in GetAiChildDivs(group)) + { + var lang = aiDiv.GetAttribute("lang"); + if (ShouldRemoveGroupAiDiv(groupInfo, aiDiv, lang, activeTags)) + { + group.RemoveChild(aiDiv); + removedCount++; + } + else if (groupInfo.IsBookTitle) + { + validBookTitleLangs.Add(lang); + } + } + } + } + + var dataDiv = FindBloomDataDiv(); + if (dataDiv != null) + { + foreach (var aiEntry in GetAiChildDivs(dataDiv)) + { + var isBookTitleEntry = + aiEntry.GetAttribute("data-book") == kBookTitleDataBookValue; + var lang = aiEntry.GetAttribute("lang"); + // Any non-bookTitle AI entry in the data div is never written by this class, + // so it's inherently stale/orphaned. A bookTitle entry is only valid if its + // matching page-level translation div is still current (see loop above). + if (!isBookTitleEntry || !validBookTitleLangs.Contains(lang)) + { + dataDiv.RemoveChild(aiEntry); + removedCount++; + } + } + } + + return removedCount; + } + + /// + /// Removes every AI-generated translation div from the book -- all "-x-ai" divs under any + /// bloom-translationGroup and all matching #bloomDataDiv entries -- regardless of engine, + /// target language, or staleness. Unlike RemoveStaleAiDivs, this also removes current, + /// in-use translations: it is the "remove all AI source translations" operation, and does + /// not depend on which engines/target language the scanner was constructed with. Returns + /// the total number of divs removed. + /// + public int RemoveAllAiDivs() + { + var removedCount = 0; + + foreach (var page in SafeXmlElement.GetAllDivsWithClass(_bookDom.Body, "bloom-page")) + { + foreach ( + var group in SafeXmlElement.GetAllDivsWithClass(page, "bloom-translationGroup") + ) + { + foreach (var aiDiv in GetAiChildDivs(group)) + { + group.RemoveChild(aiDiv); + removedCount++; + } + } + } + + var dataDiv = FindBloomDataDiv(); + if (dataDiv != null) + { + foreach (var aiEntry in GetAiChildDivs(dataDiv)) + { + dataDiv.RemoveChild(aiEntry); + removedCount++; + } + } + + return removedCount; + } + + /// + /// Computes the fingerprint stored in data-ai-fingerprint, used to detect a stale AI + /// translation: the first 16 hex characters of the SHA-256 hash of + /// "{sourceLanguageTag}\n{sourceText}\n{aiLanguageTag}". + /// + public static string ComputeFingerprint( + string sourceLanguageTag, + string sourceText, + string aiLanguageTag + ) + { + var input = $"{sourceLanguageTag}\n{sourceText}\n{aiLanguageTag}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(hash).Substring(0, 16); + } + + /// + /// Builds group info for one translation group, or returns null if the group should be + /// skipped: explicitly excluded (bloom-no-source-bubble / bloom-readOnlyInTranslationMode), + /// tied to book data other than the title, or lacking any usable source text. + /// + private AiTranslationGroupInfo TryBuildGroupInfo(SafeXmlElement group) + { + if ( + group.HasClass("bloom-no-source-bubble") + || group.HasClass("bloom-readOnlyInTranslationMode") + ) + return null; + + var editables = SafeXmlElement.GetAllDivsWithClass(group, kBloomEditableClass); + + var isBookTitle = false; + foreach (var editable in editables) + { + if (!editable.HasAttribute("data-book")) + continue; + if (editable.GetAttribute("data-book") == kBookTitleDataBookValue) + isBookTitle = true; + else + return null; // tied to some other book-data item; not ours to translate. + } + + var sourceDiv = ChooseSourceDiv(editables); + if (sourceDiv == null) + return null; + + var sourceText = (sourceDiv.InnerText ?? "").Trim(); + if (string.IsNullOrWhiteSpace(sourceText)) + return null; + + return new AiTranslationGroupInfo( + group, + sourceDiv.GetAttribute("lang"), + sourceText, + isBookTitle + ); + } + + /// + /// Chooses which bloom-editable's text is the translation source: the first non-empty + /// match walking sourceLanguagePriorities in order (skipping AI and "z" language divs), + /// falling back to the first non-empty, non-AI, non-"z" editable if no priority matches. + /// + private SafeXmlElement ChooseSourceDiv(IEnumerable editables) + { + var candidates = editables + .Where(e => e.HasAttribute("lang")) + .Where(e => !e.GetAttribute("lang").Contains(kAiLangTagFragment)) + .Where(e => e.GetAttribute("lang") != kZeroLang) + .ToList(); + + foreach (var priorityLang in _sourceLanguagePriorities) + { + var match = candidates.FirstOrDefault(e => + e.GetAttribute("lang") == priorityLang + && !string.IsNullOrWhiteSpace(e.InnerText) + ); + if (match != null) + return match; + } + + return candidates.FirstOrDefault(e => !string.IsNullOrWhiteSpace(e.InnerText)); + } + + /// + /// Direct child divs of parent whose lang attribute contains "-x-ai" (AI-generated content + /// of any provider/target, active or not). + /// + private static List GetAiChildDivs(SafeXmlElement parent) + { + return parent + .SafeSelectElements($"div[@lang and contains(@lang, '{kAiLangTagFragment}')]") + .ToList(); + } + + /// + /// Decides whether one existing AI div under a translation group should be removed: + /// always if the group is no longer eligible for AI translation at all (its AI content is + /// orphaned), otherwise if its language isn't currently active (disabled engine or changed + /// target language), or its fingerprint no longer matches the group's current source text. + /// + private static bool ShouldRemoveGroupAiDiv( + AiTranslationGroupInfo groupInfo, + SafeXmlElement aiDiv, + string lang, + HashSet activeTags + ) + { + if (groupInfo == null) + return true; + + if (!activeTags.Contains(lang)) + return true; + + var text = (aiDiv.InnerText ?? "").Trim(); + if (string.IsNullOrEmpty(text)) + return true; + + var expectedFingerprint = ComputeFingerprint( + groupInfo.SourceLanguageTag, + groupInfo.SourceText, + lang + ); + return aiDiv.GetAttribute("data-ai-fingerprint") != expectedFingerprint; + } + + /// Finds the book's #bloomDataDiv element, or null if it isn't present. + private SafeXmlElement FindBloomDataDiv() + { + return _bookDom.RawDom.SelectSingleNode("//div[@id='bloomDataDiv']") as SafeXmlElement; + } + + /// + /// Creates or replaces the direct child div of parent with the given lang: removes any + /// existing match (by lang, or by matchAttribute/matchValue when given, for cases like + /// #bloomDataDiv where multiple data-book items could share a lang), then appends a fresh + /// div with that lang and the given text (set via InnerText so markup in translations can't + /// inject HTML), letting configureAttributes add any extra attributes/classes. + /// + private static void WriteChildDiv( + SafeXmlElement parent, + string lang, + string text, + Action configureAttributes, + string matchAttribute = null, + string matchValue = null + ) + { + var xpath = + matchAttribute == null + ? $"div[@lang='{lang}']" + : $"div[@lang='{lang}' and @{matchAttribute}='{matchValue}']"; + var existing = parent.SafeSelectElements(xpath).FirstOrDefault(); + if (existing != null) + parent.RemoveChild(existing); + + var newDiv = parent.AppendChild("div"); + newDiv.SetAttribute("lang", lang); + configureAttributes(newDiv); + newDiv.InnerText = text; + } + } +} diff --git a/src/BloomExe/AiTranslation/AiTranslationBookUpdater.cs b/src/BloomExe/AiTranslation/AiTranslationBookUpdater.cs new file mode 100644 index 000000000000..21188b0887c1 --- /dev/null +++ b/src/BloomExe/AiTranslation/AiTranslationBookUpdater.cs @@ -0,0 +1,405 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using Bloom.Api; +using Bloom.Book; +using Bloom.Collection; +using Bloom.MiscUI; +using Bloom.Properties; +using Bloom.SubscriptionAndFeatures; +using Bloom.web; +using L10NSharp; +using SIL.Reporting; + +namespace Bloom.AiTranslation +{ + /// + /// Delegate matching AiTranslationService.TranslateSegmentsAsync's signature, so tests can + /// substitute a fake translator without any network calls or a real AiTranslationService. + /// + public delegate Task AiTranslateSegmentsDelegate( + AiTranslationEngineSettings engine, + string[] segments, + string sourceLanguageTag, + CancellationToken ct + ); + + /// + /// One engine's outcome from the parallel translation phase of AiTranslationBookUpdater: either + /// the (group, translatedText) pairs to apply, or the reason the engine failed. Nothing here is + /// written to the book DOM until every engine's work has settled -- see + /// AiTranslationBookUpdater.ApplyOutcomes. + /// + public class AiTranslationEngineOutcome + { + /// The engine this outcome is for. + public AiTranslationEngineSettings Engine { get; } + + /// The (group, translatedText) pairs this engine produced, not yet applied to the DOM. + public List<(AiTranslationGroupInfo Group, string TranslatedText)> Translations { get; } = + new List<(AiTranslationGroupInfo, string)>(); + + /// Null if this engine's translation succeeded; otherwise the error message. + public string ErrorMessage { get; set; } + + /// True if this engine completed without error. + public bool Succeeded => ErrorMessage == null; + + /// Creates an (initially empty, successful) outcome for the given engine. + public AiTranslationEngineOutcome(AiTranslationEngineSettings engine) + { + Engine = engine; + } + } + + /// + /// Orchestrates whole-book AI translation: scans a book for translation work + /// across the collection's active engines and, if any is found (missing/stale translations for + /// an active engine, or orphaned AI content that needs cleanup), blocks behind a modal progress + /// dialog while the engines translate in parallel, then applies every result, removes stale AI + /// content, and saves the book. Called once per book, right before the user starts editing it -- + /// see EditingModel.OnBecomeVisible. If nothing needs doing (the normal case once a book is up to + /// date), this returns immediately: no dialog, no network calls, no book changes. + /// + public class AiTranslationBookUpdater + { + private readonly AiTranslationService _aiTranslationService; + private readonly CollectionSettings _collectionSettings; + private readonly IBloomWebSocketServer _webSocketServer; + + /// + /// Creates an updater for the current collection (AiTranslationService and CollectionSettings + /// are both scoped one-per-collection by the normal Autofac project scope). + /// + public AiTranslationBookUpdater( + AiTranslationService aiTranslationService, + CollectionSettings collectionSettings, + BloomWebSocketServer webSocketServer + ) + { + _aiTranslationService = aiTranslationService; + _collectionSettings = collectionSettings; + _webSocketServer = webSocketServer; + } + + /// + /// Scans the given book and, if any active engine needs to translate something or stale AI + /// content needs removing, blocks (showing a modal progress dialog) until the work is done, + /// applied, and saved -- or the user cancels, in which case the book is left completely + /// untouched. Safe and near-instant to call when nothing needs doing (the normal case once a + /// book is up to date): no network calls are made unless there is confirmed work. + /// + public void UpdateBookIfNeeded(Book.Book book) + { + var featureStatus = FeatureStatus.GetFeatureStatus( + _collectionSettings.Subscription, + FeatureName.AiSourceBubbles + ); + var targetLanguageTag = _collectionSettings.AiTranslationTargetLanguageTag; + if ( + !featureStatus.Visible + || !featureStatus.Enabled + || string.IsNullOrWhiteSpace(targetLanguageTag) + ) + return; + + var activeEngines = AiTranslationService.GetActiveEngines(_collectionSettings); + if (activeEngines.Count == 0) + return; + + var sourceLanguagePriorities = GetSourceLanguagePriorities(); + var bookDom = book.OurHtmlDom; + var scanner = new AiTranslationBookScanner( + bookDom, + targetLanguageTag, + activeEngines, + sourceLanguagePriorities + ); + var scan = scanner.Scan(); + + var hasTranslationWork = activeEngines.Any(engine => + scan.GroupsNeedingTranslation(engine).Count > 0 + ); + var staleDivCount = CountStaleAiDivsWithoutMutating( + bookDom, + targetLanguageTag, + activeEngines, + sourceLanguagePriorities + ); + if (!hasTranslationWork && staleDivCount == 0) + return; // nothing to do; this is the normal case once a book is up to date. + + RunWithProgressDialog(book, scanner, scan, activeEngines); + } + + /// + /// Runs the parallel translation phase (via RunEnginesAsync) behind a modal progress dialog, + /// then applies the results, removes stale AI content, and saves the book -- unless the user + /// cancelled, in which case nothing is applied, cleaned up, or saved. + /// + private void RunWithProgressDialog( + Book.Book book, + AiTranslationBookScanner scanner, + AiTranslationBookScan scan, + List activeEngines + ) + { + BrowserProgressDialog.DoWorkWithProgressDialog( + _webSocketServer, + MakeDialog, + (progress, worker) => + { + using (var cts = new CancellationTokenSource()) + using ( + new System.Threading.Timer( + _ => + { + if (worker.CancellationPending) + cts.Cancel(); + }, + null, + 0, + 150 + ) + ) + { + List outcomes; + try + { + outcomes = RunEnginesAsync( + scan, + activeEngines, + _aiTranslationService.TranslateSegmentsAsync, + progress, + cts.Token + ) + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException) + { + progress.Message( + "EditTab.AiTranslation.Cancelled", + "Cancelled.", + useL10nIdPrefix: false + ); + return false; // book left completely untouched; close the dialog. + } + + ApplyOutcomes(scanner, outcomes); + var removedStaleCount = scanner.RemoveStaleAiDivs(); + book.Save(); + Logger.WriteEvent( + $"AI translation: translated {outcomes.Sum(o => o.Translations.Count)} text box(es) " + + $"across {outcomes.Count} engine(s); removed {removedStaleCount} stale AI div(s)." + ); + + // Always keep the dialog open once the run finishes (whether or not any + // engine failed) so the user can read the per-engine result lines and + // dismiss it with the OK button when ready. The Cancel button is only + // present while work is in progress; on finishing, the dialog swaps it for + // OK (and, if there were errors, also shows the Report button). + return true; + } + }, + owner: Shell.GetShellOrOtherOpenForm() + ); + } + + /// + /// Builds the modal Form used to host the progress dialog. This uses the older Form-based + /// BrowserProgressDialog overload (ShowDialog(), which blocks the caller until the dialog + /// closes) rather than the newer overload that embeds a progress dialog in an already-open web + /// page. UpdateBookIfNeeded runs synchronously from EditingModel.OnBecomeVisible, before any + /// Edit tab page -- and thus before any EmbeddedProgressDialog -- has been loaded, so there is + /// no host page available at this point in the app lifecycle. The Form-based overload's + /// ShowDialog() blocking behavior is also exactly what this feature needs: editing must not + /// start until translation has completed, been cancelled, or failed. + /// + private static Form MakeDialog() + { + var dlg = new ReactDialog( + "progressDialogBundle", + new + { + title = LocalizationManager.GetDynamicString( + appId: "Bloom", + id: "EditTab.AiTranslation.DialogTitle", + englishText: "Translating" + ), + titleColor = "white", + titleBackgroundColor = Palette.kBloomBlueHex, + showReportButton = "if-error", + showCancelButton = true, + // When the run finishes, dismiss with an OK button rather than the generic + // Close button (see ProgressDialog.tsx). Until then, only Cancel is shown. + showOkButtonWhenDone = true, + }, + "Translating" + ); + dlg.SetScaledSize(620, 400); + return dlg; + } + + /// + /// Runs every engine that has translation work, in parallel, collecting (group, translatedText) + /// results in memory without touching the book DOM (see ApplyOutcomes for that). Each engine's + /// segments are grouped by source language so translate is called once per language per engine. + /// A per-engine failure is isolated into that engine's outcome and does not affect the others; + /// cancellation (via ct) aborts every engine and propagates out of this method entirely, since a + /// cancelled run must not apply anything. + /// + internal static async Task> RunEnginesAsync( + AiTranslationBookScan scan, + IEnumerable engines, + AiTranslateSegmentsDelegate translate, + IWebSocketProgress progress, + CancellationToken ct + ) + { + var workByEngine = engines + .Select(engine => new + { + Engine = engine, + Groups = scan.GroupsNeedingTranslation(engine), + }) + .Where(w => w.Groups.Count > 0) + .ToList(); + + var tasks = workByEngine.Select(w => + TranslateOneEngineAsync(w.Engine, w.Groups, translate, progress, ct) + ); + var outcomes = await Task.WhenAll(tasks); + return outcomes.ToList(); + } + + /// + /// Translates one engine's groups, grouped by source language, isolating any failure into the + /// returned outcome (cancellation is the one exception: it is rethrown so it aborts the whole + /// run rather than being recorded as this engine's failure). + /// + private static async Task TranslateOneEngineAsync( + AiTranslationEngineSettings engine, + List groups, + AiTranslateSegmentsDelegate translate, + IWebSocketProgress progress, + CancellationToken ct + ) + { + var outcome = new AiTranslationEngineOutcome(engine); + var engineName = AiTranslationService.GetProviderDisplayName(engine.ProviderId); + try + { + progress.MessageWithParams( + "EditTab.AiTranslation.EngineTranslating", + "{0} is the translation engine's name (e.g. DeepL); {1} is how many text boxes are being translated", + "{0}: translating {1} text box(es)...", + ProgressKind.Progress, + engineName, + groups.Count + ); + + foreach (var languageGroup in groups.GroupBy(g => g.SourceLanguageTag)) + { + ct.ThrowIfCancellationRequested(); + var groupList = languageGroup.ToList(); + var segments = groupList.Select(g => g.SourceText).ToArray(); + var translated = await translate(engine, segments, languageGroup.Key, ct); + for (var i = 0; i < groupList.Count; i++) + { + outcome.Translations.Add((groupList[i], translated[i])); + } + } + + progress.MessageWithParams( + "EditTab.AiTranslation.EngineDone", + "{0} is the translation engine's name (e.g. DeepL)", + "{0}: done.", + ProgressKind.Progress, + engineName + ); + } + catch (OperationCanceledException) + { + throw; // cancellation aborts the whole run; not a per-engine failure. + } + catch (Exception ex) + { + outcome.ErrorMessage = ex.Message; + progress.MessageWithParams( + "EditTab.AiTranslation.EngineError", + "{0} is the translation engine's name (e.g. DeepL); {1} is the error message", + "{0}: {1}", + ProgressKind.Error, + engineName, + ex.Message + ); + } + + return outcome; + } + + /// + /// Writes every collected translation to the book DOM via scanner.ApplyTranslation. Called only + /// after all engines have settled, and only when the run was not cancelled. + /// + internal static void ApplyOutcomes( + AiTranslationBookScanner scanner, + IEnumerable outcomes + ) + { + foreach (var outcome in outcomes) + { + foreach (var (group, translatedText) in outcome.Translations) + { + scanner.ApplyTranslation(group, outcome.Engine, translatedText); + } + } + } + + /// + /// Counts how many stale AI divs RemoveStaleAiDivs() would remove, without mutating the real + /// book DOM: runs the (otherwise identical) removal against a throwaway clone. + /// + internal static int CountStaleAiDivsWithoutMutating( + HtmlDom bookDom, + string targetLanguageTag, + IEnumerable activeEngines, + IReadOnlyList sourceLanguagePriorities + ) + { + var probeDom = new HtmlDom(bookDom.RawDom.Clone()); + var probeScanner = new AiTranslationBookScanner( + probeDom, + targetLanguageTag, + activeEngines, + sourceLanguagePriorities + ); + return probeScanner.RemoveStaleAiDivs(); + } + + /// + /// The ordered list of candidate source languages to prefer when a translation group has more + /// than one bloom-editable to choose from: the user's last-viewed source languages, then the + /// collection's L2/L3 tags, then English, deduped and with blanks removed. + /// + private IReadOnlyList GetSourceLanguagePriorities() + { + return new[] + { + Settings.Default.LastSourceLanguageViewed, + Settings.Default.LastSourceLanguageViewed2, + _collectionSettings.Language2Tag, + _collectionSettings.Language3Tag, + "en", + } + .Where(tag => !string.IsNullOrEmpty(tag)) + .Distinct() + .ToList(); + } + } +} diff --git a/src/BloomExe/AiTranslation/AiTranslationEngineSettings.cs b/src/BloomExe/AiTranslation/AiTranslationEngineSettings.cs new file mode 100644 index 000000000000..9072e0a50320 --- /dev/null +++ b/src/BloomExe/AiTranslation/AiTranslationEngineSettings.cs @@ -0,0 +1,69 @@ +namespace Bloom.AiTranslation +{ + /// + /// Per-engine configuration and validation state for one AI translation provider + /// ("deepl", "google", or "alpha2"). A CollectionSettings always has exactly one of these for + /// each known provider id; see CollectionSettings.EnsureAiTranslationEngines(). + /// + public class AiTranslationEngineSettings + { + /// + /// The provider this engine configuration is for: "deepl", "google", or "alpha2". + /// + public string ProviderId; + + /// + /// Whether this engine is turned on for whole-book batch translation. + /// + public bool Enabled; + + /// + /// API key used by the DeepL and Alpha2 providers. + /// + public string ApiKey = ""; + + /// + /// Service account email used by the Google provider. + /// + public string ServiceAccountEmail = ""; + + /// + /// Service account private key used by the Google provider. + /// + public string PrivateKey = ""; + + /// + /// Fingerprint (see AiTranslationService.GetEngineFingerprint) of this engine's + /// configuration and the target language at the time it was last validated. + /// + public string ValidatedConfigurationFingerprint = ""; + + /// + /// Whether the last validation attempt for this engine succeeded. + /// + public bool LastValidationSucceeded; + + /// + /// The message (success text or error) from the last validation attempt. + /// + public string LastValidationMessage = ""; + + /// + /// Creates a deep (independent) copy of this engine's settings. + /// + public AiTranslationEngineSettings Clone() + { + return new AiTranslationEngineSettings + { + ProviderId = ProviderId, + Enabled = Enabled, + ApiKey = ApiKey, + ServiceAccountEmail = ServiceAccountEmail, + PrivateKey = PrivateKey, + ValidatedConfigurationFingerprint = ValidatedConfigurationFingerprint, + LastValidationSucceeded = LastValidationSucceeded, + LastValidationMessage = LastValidationMessage, + }; + } + } +} diff --git a/src/BloomExe/AiTranslation/AiTranslationService.cs b/src/BloomExe/AiTranslation/AiTranslationService.cs new file mode 100644 index 000000000000..ec5e87fd8817 --- /dev/null +++ b/src/BloomExe/AiTranslation/AiTranslationService.cs @@ -0,0 +1,501 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Bloom.Collection; +using Bloom.Utils; +using Newtonsoft.Json.Linq; +using SIL.WritingSystems; + +namespace Bloom.AiTranslation +{ + /// + /// Result of validating one AI translation engine's configuration. + /// + public class AiTranslationValidationResult + { + public bool Succeeded { get; set; } + public string ConfigurationFingerprint { get; set; } + public string Message { get; set; } + } + + /// + /// Coordinates collection-backed AI translation across the 0..n engines + /// configured on a collection (deepl, google, alpha2). Translation is always batched: callers + /// pass an array of text segments per engine and get back translations in the same order. + /// + public class AiTranslationService + { + public const string kValidationProbeText = "Today a reader, tomorrow a leader."; + public const string kValidationProbeSourceLanguageTag = "en"; + + private static readonly HttpClient _httpClient = new HttpClient(); + private readonly CollectionSettings _collectionSettings; + private readonly Dictionary _providers; + + public AiTranslationService(CollectionSettings collectionSettings) + : this(collectionSettings, null) { } + + internal AiTranslationService( + CollectionSettings collectionSettings, + Dictionary providers + ) + { + _collectionSettings = collectionSettings; + _providers = + providers + ?? new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "deepl", new DeepLTranslationProvider() }, + { "google", new GoogleTranslationProvider() }, + { "alpha2", new Alpha2TranslationProvider() }, + }; + } + + /// + /// Translates all segments via the given engine, chunking requests to the provider's + /// per-request segment-count and byte-size limits. Chunks are sent sequentially; the + /// returned array preserves segment order and count, and this throws if any chunk's + /// response doesn't have exactly as many translations as segments sent. + /// + public async Task TranslateSegmentsAsync( + AiTranslationEngineSettings engine, + string[] segments, + string sourceLanguageTag, + CancellationToken ct + ) + { + if (engine == null) + throw new ArgumentNullException(nameof(engine)); + if (segments == null) + throw new ArgumentNullException(nameof(segments)); + + var targetLanguageTag = NormalizeBloomLanguageTag( + _collectionSettings.AiTranslationTargetLanguageTag + ); + if (string.IsNullOrWhiteSpace(targetLanguageTag)) + { + throw new InvalidOperationException( + "Set a target language tag in Collection Settings > AI Source Bubbles." + ); + } + + var provider = GetProvider(engine.ProviderId); + var results = new string[segments.Length]; + var resultIndex = 0; + foreach ( + var chunk in ChunkSegments( + segments, + provider.MaxSegmentsPerRequest, + provider.MaxRequestBytes + ) + ) + { + var translatedChunk = await provider.TranslateBatchAsync( + engine, + chunk, + sourceLanguageTag, + targetLanguageTag, + _httpClient, + ct + ); + if (translatedChunk.Length != chunk.Length) + { + throw new InvalidOperationException( + $"{provider.ProviderId} returned {translatedChunk.Length} translations for a chunk of {chunk.Length} segments." + ); + } + Array.Copy(translatedChunk, 0, results, resultIndex, translatedChunk.Length); + resultIndex += translatedChunk.Length; + } + + return results; + } + + /// + /// Splits segments into chunks that respect both a maximum segment count and a maximum + /// total UTF-8 byte size per chunk. A single segment whose own byte size exceeds the + /// byte cap is still sent, alone, in its own chunk. + /// + internal static IEnumerable ChunkSegments( + string[] segments, + int maxSegmentsPerRequest, + int maxRequestBytes + ) + { + var currentChunk = new List(); + var currentBytes = 0; + foreach (var segment in segments) + { + var segmentBytes = Encoding.UTF8.GetByteCount(segment ?? string.Empty); + var wouldExceedCount = currentChunk.Count >= maxSegmentsPerRequest; + var wouldExceedBytes = + currentChunk.Count > 0 && currentBytes + segmentBytes > maxRequestBytes; + if (wouldExceedCount || wouldExceedBytes) + { + yield return currentChunk.ToArray(); + currentChunk = new List(); + currentBytes = 0; + } + + currentChunk.Add(segment); + currentBytes += segmentBytes; + } + + if (currentChunk.Count > 0) + { + yield return currentChunk.ToArray(); + } + } + + /// + /// Validates one engine's configuration, credentials, and target language with a probe translation. + /// + public async Task ValidateEngineAsync( + AiTranslationEngineSettings engine, + CancellationToken ct + ) + { + var translations = await TranslateSegmentsAsync( + engine, + new[] { kValidationProbeText }, + kValidationProbeSourceLanguageTag, + ct + ); + + return new AiTranslationValidationResult + { + Succeeded = true, + ConfigurationFingerprint = GetEngineFingerprint( + engine, + _collectionSettings.AiTranslationTargetLanguageTag + ), + Message = translations[0], + }; + } + + /// + /// Gets the union of target languages supported by all ENABLED engines on the collection, + /// deduped by language tag (each option records which of those engines' providers support it). + /// + public async Task> GetSupportedTargetLanguagesAsync( + CancellationToken ct + ) + { + var optionsByTag = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + var failures = new List(); + foreach (var engine in _collectionSettings.AiTranslationEngines.Where(e => e.Enabled)) + { + var provider = GetProvider(engine.ProviderId); + List options; + try + { + options = await provider.GetSupportedTargetLanguagesAsync( + engine, + _httpClient, + ct + ); + } + catch (Exception e) + when (e is HttpRequestException + || e is InvalidOperationException + || e is ArgumentException + || e is CryptographicException + || e is Newtonsoft.Json.JsonException + ) + { + // One engine failing to list its languages -- e.g. a DeepL key that can + // translate but lacks the "languages:read" scope -- must not blank out the + // whole union. Other engines can still contribute the list, and this engine may + // still translate fine. Remember the failure only so we can report it if NO + // engine manages to return any languages. + failures.Add($"{GetProviderDisplayName(provider.ProviderId)}: {e.Message}"); + continue; + } + + foreach (var option in options) + { + if (optionsByTag.TryGetValue(option.Value, out var existing)) + { + existing.ProviderIds.Add(provider.ProviderId); + } + else + { + optionsByTag[option.Value] = option; + } + } + } + + // Only surface an error when every enabled engine failed to provide any languages; as + // long as one succeeded, the union we return is enough to populate the dropdown. + if (optionsByTag.Count == 0 && failures.Count > 0) + throw new InvalidOperationException(string.Join(" ", failures)); + + return optionsByTag + .Values.OrderBy(o => o.Label, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + } + + /// + /// Builds the Bloom language tag used for AI content in a translation group for the given engine. + /// + public string GetAiLanguageTagForEngine(AiTranslationEngineSettings engine) + { + return GetAiLanguageTag( + _collectionSettings.AiTranslationTargetLanguageTag, + engine.ProviderId + ); + } + + /// + /// Returns the human-facing name of a translation engine (e.g. "DeepL"), for use in + /// progress messages and other UI. Engine/product names are not localized. + /// + public static string GetProviderDisplayName(string providerId) + { + switch (NormalizeProviderId(providerId)) + { + case "deepl": + return "DeepL"; + case "google": + return "Google Translate"; + case "alpha2": + return "SIL Alpha2"; + default: + return providerId; + } + } + + /// + /// Builds the Bloom language tag used for AI content in a translation group. + /// + public static string GetAiLanguageTag(string targetLanguageTag, string providerId) + { + var normalizedTarget = NormalizeBloomLanguageTag(targetLanguageTag); + var normalizedProvider = NormalizeProviderId(providerId); + if ( + string.IsNullOrWhiteSpace(normalizedTarget) + || string.IsNullOrWhiteSpace(normalizedProvider) + ) + { + return string.Empty; + } + + return $"{normalizedTarget}-x-ai-{normalizedProvider}"; + } + + /// + /// Normalizes the provider id used in settings, API payloads, and AI language tags. + /// + public static string NormalizeProviderId(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + return string.Empty; + + var normalized = providerId.Trim().ToLowerInvariant(); + return normalized switch + { + "googletranslate" => "google", + _ => normalized, + }; + } + + /// + /// Normalizes a Bloom language tag for provider requests while preserving region when available. + /// + public static string NormalizeBloomLanguageTag(string languageTag) + { + if (string.IsNullOrWhiteSpace(languageTag)) + return string.Empty; + + var trimmed = languageTag.Trim(); + var privateUseIndex = trimmed.IndexOf("-x-", StringComparison.OrdinalIgnoreCase); + if (privateUseIndex >= 0) + { + trimmed = trimmed.Substring(0, privateUseIndex); + } + + trimmed = MiscUtils.NormalizeLanguageTagCapitalization(trimmed); + if ( + !IetfLanguageTag.TryGetParts( + trimmed, + out var language, + out var script, + out var region, + out var variant + ) || string.IsNullOrWhiteSpace(language) + ) + { + return trimmed; + } + + language = language.ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(region)) + { + return $"{language}-{region.ToUpperInvariant()}"; + } + + return language; + } + + /// + /// Extracts the Google Cloud project id from a service account email. + /// + public static string GetGoogleProjectIdFromServiceAccountEmail(string serviceAccountEmail) + { + if (string.IsNullOrWhiteSpace(serviceAccountEmail)) + { + throw new InvalidOperationException( + "Set a Google service account email in Collection Settings > AI Source Bubbles." + ); + } + + var trimmedEmail = serviceAccountEmail.Trim(); + var atIndex = trimmedEmail.IndexOf('@'); + if (atIndex < 0 || atIndex == trimmedEmail.Length - 1) + { + throw new InvalidOperationException( + "Google service account email is not in the expected format." + ); + } + + var domain = trimmedEmail.Substring(atIndex + 1); + const string kExpectedSuffix = ".iam.gserviceaccount.com"; + if (!domain.EndsWith(kExpectedSuffix, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Google service account email must end with .iam.gserviceaccount.com." + ); + } + + var projectId = domain.Substring(0, domain.Length - kExpectedSuffix.Length); + if (string.IsNullOrWhiteSpace(projectId)) + { + throw new InvalidOperationException( + "Google service account email does not contain a project id." + ); + } + + return projectId; + } + + /// + /// The subset of a collection's configured engines that are actually ready to translate with: + /// enabled, and last validated against the exact configuration (provider, target language, + /// credentials) they currently have. This is the single source of truth for "is this engine + /// active" -- both the editor's allowAiSourceBubbles flag (see RuntimeInformationInjector) and + /// the whole-book batch updater (AiTranslationBookUpdater) must agree on it. + /// + public static List GetActiveEngines( + CollectionSettings collectionSettings + ) + { + var targetLanguageTag = collectionSettings.AiTranslationTargetLanguageTag; + return collectionSettings + .AiTranslationEngines.Where(engine => + engine.Enabled + && engine.LastValidationSucceeded + && string.Equals( + engine.ValidatedConfigurationFingerprint, + GetEngineFingerprint(engine, targetLanguageTag), + StringComparison.Ordinal + ) + ) + .ToList(); + } + + /// + /// Builds a stable fingerprint of one engine's provider, target language, and + /// provider-specific credentials, without storing raw secrets. + /// + public static string GetEngineFingerprint( + AiTranslationEngineSettings engine, + string targetLanguageTag + ) + { + if (engine == null) + throw new ArgumentNullException(nameof(engine)); + + var normalizedProvider = NormalizeProviderId(engine.ProviderId); + var normalizedTargetLanguageTag = NormalizeBloomLanguageTag(targetLanguageTag); + var credentialKey = normalizedProvider switch + { + "google" => + $"{engine.ServiceAccountEmail?.Trim()}\n{AiTranslationProviderHelpers.NormalizeGooglePrivateKey(engine.PrivateKey)}", + _ => engine.ApiKey?.Trim() ?? string.Empty, + }; + var fingerprintInput = + $"{normalizedProvider}\n{normalizedTargetLanguageTag}\n{credentialKey}"; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(fingerprintInput))); + } + + private IAiTranslationProvider GetProvider(string providerId) + { + var normalizedProviderId = NormalizeProviderId(providerId); + if (string.IsNullOrWhiteSpace(normalizedProviderId)) + { + throw new InvalidOperationException( + "An AI translation engine must have a provider id." + ); + } + + if (!_providers.TryGetValue(normalizedProviderId, out var provider)) + { + throw new InvalidOperationException( + $"Unsupported AI provider '{normalizedProviderId}'." + ); + } + + return provider; + } + } + + internal static class AiTranslationProviderHelpers + { + internal static string NormalizeGooglePrivateKey(string privateKey) + { + return (privateKey ?? string.Empty).Replace("\\r", "").Replace("\\n", "\n").Trim(); + } + + internal static void EnsureSuccess( + HttpResponseMessage response, + string responseContent, + string providerName + ) + { + if (response.IsSuccessStatusCode) + return; + + var providerMessage = responseContent; + try + { + var json = JObject.Parse(responseContent); + providerMessage = + json["message"]?.Value() + ?? json["error"]?.Value() + ?? json["error"]?["message"]?.Value() + ?? json["detail"]?[0]?["msg"]?.Value() + ?? responseContent; + } + catch + { + // Keep the original response text when it isn't JSON. + } + + throw new InvalidOperationException( + $"{providerName} request failed: {(int)response.StatusCode} {response.ReasonPhrase}. {providerMessage}".Trim() + ); + } + + internal static string Base64UrlEncode(byte[] bytes) + { + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + } +} diff --git a/src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs b/src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs new file mode 100644 index 000000000000..34c2599c6728 --- /dev/null +++ b/src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs @@ -0,0 +1,463 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Bloom.Collection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SIL.WritingSystems; + +namespace Bloom.AiTranslation +{ + /// + /// AI translation provider backed by the SIL Alpha2 text-collection API + /// (https://alpha2.multilingualai.com). Unlike DeepL/Google, translation here is + /// collection-based and asynchronous: segments are uploaded as a "text collection", a + /// translation job is kicked off against a chosen model, and the caller polls until every + /// segment's translation is complete. + /// + internal sealed class Alpha2TranslationProvider : IAiTranslationProvider + { + private const string kApiBaseUrl = "https://alpha2.multilingualai.com/api"; + + // How often to re-poll a text collection for completed translations, and how long to + // keep polling before giving up. Observed live round trips for tiny batches complete + // within one poll (~4s); larger/production batches are untested at scale, hence the + // generous overall timeout. + private static readonly TimeSpan kPollInterval = TimeSpan.FromSeconds(3); + private static readonly TimeSpan kPollTimeout = TimeSpan.FromMinutes(5); + + public string ProviderId => "alpha2"; + public int MaxSegmentsPerRequest => 500; + public int MaxRequestBytes => 400_000; + + /// + /// Alpha2 has no supported-languages matrix: /v2/translation_models only returns + /// {id, name} per model, with no src/trg metadata, so there is no way to enumerate which + /// target languages are supported without querying every language pair up front. Alpha2 + /// viability for a given pair is instead proven by the validation probe + /// (TranslateBatchAsync), which throws a clear "no translation model" error when a pair + /// isn't supported. So the settings UI's union dropdown simply won't include + /// Alpha2-only languages. + /// + public Task> GetSupportedTargetLanguagesAsync( + AiTranslationEngineSettings engine, + HttpClient httpClient, + CancellationToken ct + ) + { + return Task.FromResult(new List()); + } + + /// + /// Translates a batch of segments via Alpha2's text-collection flow: create a source + /// collection, kick off a translation against the first available model for the + /// language pair, poll until every segment is complete, then delete both the source and + /// output collections. + /// + public async Task TranslateBatchAsync( + AiTranslationEngineSettings engine, + string[] segments, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient, + CancellationToken ct + ) + { + EnsureCredentials(engine); + + var apiKey = engine.ApiKey.Trim(); + var srcIso3 = MapToIso6393(sourceLanguageTag); + var trgIso3 = MapToIso6393(targetLanguageTag); + + var modelId = await GetModelIdAsync(httpClient, apiKey, srcIso3, trgIso3, ct); + var sourceCollectionId = await CreateTextCollectionAsync( + httpClient, + apiKey, + srcIso3, + segments, + ct + ); + long? outputCollectionId = null; + string[] translations; + try + { + outputCollectionId = await RequestTranslationAsync( + httpClient, + apiKey, + sourceCollectionId, + trgIso3, + modelId, + ct + ); + + translations = await PollForTranslationsAsync( + httpClient, + apiKey, + sourceCollectionId, + trgIso3, + modelId, + segments.Length, + ct + ); + } + catch + { + // Best-effort cleanup on the failure path: a collection is still a real, + // storage-consuming resource even when translation failed, but a delete failure + // here must never replace/mask the original, more informative exception. + await TryDeleteBestEffortAsync(httpClient, apiKey, sourceCollectionId, ct); + if (outputCollectionId.HasValue) + { + await TryDeleteBestEffortAsync( + httpClient, + apiKey, + outputCollectionId.Value, + ct + ); + } + throw; + } + + // Both the source collection and the translate-created output collection are real, + // storage-consuming resources on the Alpha2 side; clean up both. On this success + // path a delete failure is a genuine problem, so let it propagate normally. + await DeleteTextCollectionAsync(httpClient, apiKey, sourceCollectionId, ct); + if (outputCollectionId.HasValue) + { + await DeleteTextCollectionAsync(httpClient, apiKey, outputCollectionId.Value, ct); + } + + return translations; + } + + /// + /// Maps a Bloom language tag (BCP-47, e.g. "en", "en-US", or already ISO 639-3 like + /// "eng") to the ISO 639-3 code Alpha2 requires. A tag whose primary subtag is already + /// three letters is passed through as-is; otherwise it is looked up in libpalaso's + /// registered-languages table. Throws if the tag can't be mapped. + /// + internal static string MapToIso6393(string bloomLanguageTag) + { + if (string.IsNullOrWhiteSpace(bloomLanguageTag)) + { + throw new InvalidOperationException("Alpha2 requires a language tag."); + } + + var normalized = AiTranslationService.NormalizeBloomLanguageTag(bloomLanguageTag); + var primarySubtag = normalized.Split('-')[0].ToLowerInvariant(); + if (primarySubtag.Length == 3) + { + return primarySubtag; + } + + if ( + !StandardSubtags.RegisteredLanguages.TryGet(primarySubtag, out var languageSubtag) + || string.IsNullOrWhiteSpace(languageSubtag.Iso3Code) + ) + { + throw new InvalidOperationException( + $"Cannot map language tag '{bloomLanguageTag}' to an ISO 639-3 code for Alpha2." + ); + } + + return languageSubtag.Iso3Code; + } + + /// + /// Picks the first translation model Alpha2 reports for the given language pair. Throws + /// if no model supports the pair. + /// + private static async Task GetModelIdAsync( + HttpClient httpClient, + string apiKey, + string srcIso3, + string trgIso3, + CancellationToken ct + ) + { + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"{kApiBaseUrl}/v2/translation_models?src={Uri.EscapeDataString(srcIso3)}&trg={Uri.EscapeDataString(trgIso3)}" + ); + request.Headers.Add("api_key", apiKey); + + using var response = await httpClient.SendAsync(request, ct); + var responseContent = await response.Content.ReadAsStringAsync(); + AiTranslationProviderHelpers.EnsureSuccess(response, responseContent, "Alpha2"); + + var models = JArray.Parse(responseContent); + if (models.Count == 0) + { + throw new InvalidOperationException( + $"Alpha2 has no translation model for {srcIso3} -> {trgIso3}." + ); + } + + return models[0]["id"].Value(); + } + + /// + /// Creates a source text collection holding one text per segment, preserving internal + /// newlines within a segment (using the `texts` array, not the newline-splitting `text` + /// field). Returns the new collection's id. + /// + private static async Task CreateTextCollectionAsync( + HttpClient httpClient, + string apiKey, + string srcIso3, + string[] segments, + CancellationToken ct + ) + { + var requestBody = new + { + name = $"bloom-{Guid.NewGuid():N}", + language = srcIso3, + texts = segments, + }; + + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"{kApiBaseUrl}/v2/text_collections" + ); + request.Headers.Add("api_key", apiKey); + request.Content = new StringContent( + JsonConvert.SerializeObject(requestBody), + Encoding.UTF8, + "application/json" + ); + + using var response = await httpClient.SendAsync(request, ct); + var responseContent = await response.Content.ReadAsStringAsync(); + AiTranslationProviderHelpers.EnsureSuccess(response, responseContent, "Alpha2"); + + var json = JObject.Parse(responseContent); + return json["id"].Value(); + } + + /// + /// Kicks off an asynchronous translation job for a source collection. This creates a + /// separate output collection on the Alpha2 side (returned here so it can be deleted + /// later); the source collection's own texts remain untouched. + /// + private static async Task RequestTranslationAsync( + HttpClient httpClient, + string apiKey, + long sourceCollectionId, + string trgIso3, + int modelId, + CancellationToken ct + ) + { + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"{kApiBaseUrl}/v2/text_collections/{sourceCollectionId}/translate?target_language={Uri.EscapeDataString(trgIso3)}&model_id={modelId}" + ); + request.Headers.Add("api_key", apiKey); + request.Content = new StringContent("{}", Encoding.UTF8, "application/json"); + + using var response = await httpClient.SendAsync(request, ct); + var responseContent = await response.Content.ReadAsStringAsync(); + AiTranslationProviderHelpers.EnsureSuccess(response, responseContent, "Alpha2"); + + var json = JObject.Parse(responseContent); + return json["id"]?.Value(); + } + + /// + /// Polls the source collection's texts (with translations included) until every segment + /// reaches a "complete" status, then returns the translated strings in original + /// submission order. The in-progress statuses "pending" and "running" (and a momentarily + /// missing status) are treated as not-yet-done and keep the poll going. Throws if any + /// segment reaches an unrecognized status, if the response's text count doesn't match the + /// segment count, or if the overall poll timeout elapses. + /// + private static async Task PollForTranslationsAsync( + HttpClient httpClient, + string apiKey, + long sourceCollectionId, + string trgIso3, + int modelId, + int expectedCount, + CancellationToken ct + ) + { + var deadline = DateTime.UtcNow + kPollTimeout; + while (true) + { + var texts = await GetTextsWithTranslationsAsync( + httpClient, + apiKey, + sourceCollectionId, + trgIso3, + modelId, + ct + ); + // The endpoint returns texts newest-first; sort ascending by id to recover the + // original submission order (ids are assigned monotonically at creation time). + var sortedTexts = texts.OrderBy(t => t["id"]?.Value() ?? 0L).ToList(); + + // Observed live: immediately after the translate call returns, a poll can see + // fewer texts than were submitted (the per-text translation rows aren't + // queryable yet for a brief moment). Treat missing texts as still-pending rather + // than an error; a genuinely wrong count only becomes an error once nothing is + // pending anymore (or the poll times out). + if (sortedTexts.Count > expectedCount) + { + throw new InvalidOperationException( + $"Alpha2 returned {sortedTexts.Count} texts for {expectedCount} segments." + ); + } + + var pendingCount = expectedCount - sortedTexts.Count; + foreach (var text in sortedTexts) + { + var translation = (text["translations"] as JArray)?.FirstOrDefault(); + var status = translation?["translation_status"]?.Value(); + // Complete segments are done; in-progress ones keep the poll going; anything + // else throws (see IsTranslationComplete). + if (!IsTranslationComplete(status)) + { + pendingCount++; + } + } + + if (pendingCount == 0) + { + return sortedTexts + .Select(t => + (t["translations"] as JArray) + ?.FirstOrDefault() + ?["text"]?.Value() ?? string.Empty + ) + .ToArray(); + } + + if (DateTime.UtcNow >= deadline) + { + throw new TimeoutException( + $"Alpha2 translation timed out after {kPollTimeout.TotalMinutes} minutes with {pendingCount} of {expectedCount} texts still pending." + ); + } + + await Task.Delay(kPollInterval, ct); + } + } + + /// + /// Classifies a segment's Alpha2 translation_status while polling. Returns true when the + /// segment is finished ("complete"). Returns false for the in-progress statuses Alpha2 + /// walks a translation through — "pending" (queued) and "running" (translating) — as well + /// as a momentarily-missing status seen right after the translate call kicks off. Throws + /// for any other (unrecognized) status, which we treat as a hard failure rather than + /// silently waiting forever on a segment that will never complete. + /// + internal static bool IsTranslationComplete(string status) + { + if (string.Equals(status, "complete", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + if ( + string.IsNullOrEmpty(status) + || string.Equals(status, "pending", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "running", StringComparison.OrdinalIgnoreCase) + ) + { + return false; + } + + throw new InvalidOperationException( + $"Alpha2 translation failed for a segment: unexpected status '{status}'." + ); + } + + /// + /// Fetches the source collection's texts along with their translations for the given + /// target language/model, used both while polling and (implicitly) once complete. + /// + private static async Task GetTextsWithTranslationsAsync( + HttpClient httpClient, + string apiKey, + long sourceCollectionId, + string trgIso3, + int modelId, + CancellationToken ct + ) + { + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"{kApiBaseUrl}/v2/text_collections/{sourceCollectionId}/texts?include_translations=true&target_language={Uri.EscapeDataString(trgIso3)}&translation_model_id={modelId}" + ); + request.Headers.Add("api_key", apiKey); + + using var response = await httpClient.SendAsync(request, ct); + var responseContent = await response.Content.ReadAsStringAsync(); + AiTranslationProviderHelpers.EnsureSuccess(response, responseContent, "Alpha2"); + + return JArray.Parse(responseContent); + } + + /// + /// Deletes a text collection. Note the delete endpoint uses the singular + /// "text_collection" path, unlike every other Alpha2 endpoint which uses the plural + /// "text_collections". + /// + private static async Task DeleteTextCollectionAsync( + HttpClient httpClient, + string apiKey, + long collectionId, + CancellationToken ct + ) + { + using var request = new HttpRequestMessage( + HttpMethod.Delete, + $"{kApiBaseUrl}/v2/text_collection/{collectionId}" + ); + request.Headers.Add("api_key", apiKey); + + using var response = await httpClient.SendAsync(request, ct); + var responseContent = await response.Content.ReadAsStringAsync(); + AiTranslationProviderHelpers.EnsureSuccess(response, responseContent, "Alpha2"); + } + + /// + /// Deletes a text collection, swallowing any failure. Used only when cleaning up after a + /// translation that has already failed, where a secondary delete failure would otherwise + /// mask the original, more informative exception. + /// + private static async Task TryDeleteBestEffortAsync( + HttpClient httpClient, + string apiKey, + long collectionId, + CancellationToken ct + ) + { + try + { + await DeleteTextCollectionAsync(httpClient, apiKey, collectionId, ct); + } + catch + { + // Deliberately swallowed: see summary above. + } + } + + /// + /// Validates that an API key is configured. + /// + private static void EnsureCredentials(AiTranslationEngineSettings engine) + { + if (string.IsNullOrWhiteSpace(engine.ApiKey)) + { + throw new InvalidOperationException( + "Set an Alpha2 API key in Collection Settings > AI Source Bubbles." + ); + } + } + } +} diff --git a/src/BloomExe/AiTranslation/DeepLTranslationProvider.cs b/src/BloomExe/AiTranslation/DeepLTranslationProvider.cs new file mode 100644 index 000000000000..51c120dbd478 --- /dev/null +++ b/src/BloomExe/AiTranslation/DeepLTranslationProvider.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Bloom.Collection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Bloom.AiTranslation +{ + /// + /// AI translation provider backed by the DeepL API. + /// + internal sealed class DeepLTranslationProvider : IAiTranslationProvider + { + public string ProviderId => "deepl"; + public int MaxSegmentsPerRequest => 50; + public int MaxRequestBytes => 120_000; + + public async Task> GetSupportedTargetLanguagesAsync( + AiTranslationEngineSettings engine, + HttpClient httpClient, + CancellationToken ct + ) + { + EnsureCredentials(engine); + + using var request = new HttpRequestMessage( + HttpMethod.Get, + GetApiBaseUrl(engine.ApiKey) + "/v2/languages?type=target" + ); + request.Headers.Authorization = new AuthenticationHeaderValue( + "DeepL-Auth-Key", + engine.ApiKey.Trim() + ); + + using var response = await httpClient.SendAsync(request, ct); + var responseContent = await response.Content.ReadAsStringAsync(); + AiTranslationProviderHelpers.EnsureSuccess(response, responseContent, "DeepL"); + + var languages = JArray.Parse(responseContent); + var options = new List(); + foreach (var languageToken in languages) + { + var languageCode = languageToken["language"]?.Value(); + if (string.IsNullOrWhiteSpace(languageCode)) + { + continue; + } + + var normalizedLanguageCode = AiTranslationService.NormalizeBloomLanguageTag( + languageCode + ); + var name = languageToken["name"]?.Value() ?? normalizedLanguageCode; + options.Add( + new AiTranslationTargetLanguageOption + { + Value = normalizedLanguageCode, + Label = name, + ProviderIds = new List { ProviderId }, + } + ); + } + + options.Sort( + (first, second) => + StringComparer.CurrentCultureIgnoreCase.Compare(first.Label, second.Label) + ); + return options; + } + + public async Task TranslateBatchAsync( + AiTranslationEngineSettings engine, + string[] segments, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient, + CancellationToken ct + ) + { + EnsureCredentials(engine); + + var requestBody = new + { + text = segments, + source_lang = NormalizeDeepLLanguageTag(sourceLanguageTag), + target_lang = NormalizeDeepLLanguageTag(targetLanguageTag), + }; + + using var request = new HttpRequestMessage(HttpMethod.Post, GetEndpoint(engine.ApiKey)); + request.Content = new StringContent( + JsonConvert.SerializeObject(requestBody), + Encoding.UTF8, + "application/json" + ); + request.Headers.Authorization = new AuthenticationHeaderValue( + "DeepL-Auth-Key", + engine.ApiKey.Trim() + ); + + using var response = await httpClient.SendAsync(request, ct); + var responseContent = await response.Content.ReadAsStringAsync(); + AiTranslationProviderHelpers.EnsureSuccess(response, responseContent, "DeepL"); + + var responseJson = JObject.Parse(responseContent); + var translations = responseJson["translations"] as JArray; + if (translations == null || translations.Count != segments.Length) + { + throw new InvalidOperationException( + $"DeepL returned {translations?.Count ?? 0} translations for {segments.Length} segments." + ); + } + + return translations.Select(t => t["text"]?.Value() ?? string.Empty).ToArray(); + } + + private static void EnsureCredentials(AiTranslationEngineSettings engine) + { + if (string.IsNullOrWhiteSpace(engine.ApiKey)) + { + throw new InvalidOperationException( + "Set a DeepL API key in Collection Settings > AI Source Bubbles." + ); + } + } + + private static string GetEndpoint(string apiKey) + { + return GetApiBaseUrl(apiKey) + "/v2/translate"; + } + + private static string GetApiBaseUrl(string apiKey) + { + return apiKey.Trim().EndsWith(":fx", StringComparison.OrdinalIgnoreCase) + ? "https://api-free.deepl.com" + : "https://api.deepl.com"; + } + + private static string NormalizeDeepLLanguageTag(string languageTag) + { + return AiTranslationService + .NormalizeBloomLanguageTag(languageTag) + .Replace('_', '-') + .ToUpperInvariant(); + } + } +} diff --git a/src/BloomExe/AiTranslation/GoogleTranslationProvider.cs b/src/BloomExe/AiTranslation/GoogleTranslationProvider.cs new file mode 100644 index 000000000000..1779192972c0 --- /dev/null +++ b/src/BloomExe/AiTranslation/GoogleTranslationProvider.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Bloom.Collection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Bloom.AiTranslation +{ + /// + /// AI translation provider backed by the Google Cloud Translation API, + /// authenticated via a service account. + /// + internal sealed class GoogleTranslationProvider : IAiTranslationProvider + { + private const string kScope = "https://www.googleapis.com/auth/cloud-translation"; + private const string kTokenEndpoint = "https://oauth2.googleapis.com/token"; + private const string kTranslateEndpoint = + "https://translation.googleapis.com/language/translate/v2"; + private const string kSupportedLanguagesEndpointTemplate = + "https://translation.googleapis.com/v3/projects/{0}/locations/global/supportedLanguages?display_language_code=en"; + + public string ProviderId => "google"; + public int MaxSegmentsPerRequest => 128; + public int MaxRequestBytes => 100_000; + + public async Task> GetSupportedTargetLanguagesAsync( + AiTranslationEngineSettings engine, + HttpClient httpClient, + CancellationToken ct + ) + { + EnsureCredentials(engine); + + var accessToken = await GetAccessTokenAsync(engine, httpClient, ct); + var projectId = AiTranslationService.GetGoogleProjectIdFromServiceAccountEmail( + engine.ServiceAccountEmail + ); + using var request = new HttpRequestMessage( + HttpMethod.Get, + string.Format(kSupportedLanguagesEndpointTemplate, Uri.EscapeDataString(projectId)) + ); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await httpClient.SendAsync(request, ct); + var responseContent = await response.Content.ReadAsStringAsync(); + AiTranslationProviderHelpers.EnsureSuccess( + response, + responseContent, + "Google Translate" + ); + + var responseJson = JObject.Parse(responseContent); + var languages = responseJson["languages"] as JArray; + var options = new List(); + if (languages == null) + { + return options; + } + + foreach (var languageToken in languages) + { + var supportsTarget = languageToken["supportTarget"]?.Value() ?? false; + if (!supportsTarget) + { + continue; + } + + var languageCode = languageToken["languageCode"]?.Value(); + if (string.IsNullOrWhiteSpace(languageCode)) + { + continue; + } + + var normalizedLanguageCode = AiTranslationService.NormalizeBloomLanguageTag( + languageCode + ); + var displayName = + languageToken["displayName"]?.Value() ?? normalizedLanguageCode; + options.Add( + new AiTranslationTargetLanguageOption + { + Value = normalizedLanguageCode, + Label = displayName, + ProviderIds = new List { ProviderId }, + } + ); + } + + options.Sort( + (first, second) => + StringComparer.CurrentCultureIgnoreCase.Compare(first.Label, second.Label) + ); + return options; + } + + public async Task TranslateBatchAsync( + AiTranslationEngineSettings engine, + string[] segments, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient, + CancellationToken ct + ) + { + EnsureCredentials(engine); + + var accessToken = await GetAccessTokenAsync(engine, httpClient, ct); + var fields = new List>(); + foreach (var segment in segments) + { + fields.Add(new KeyValuePair("q", segment)); + } + fields.Add(new KeyValuePair("target", targetLanguageTag)); + fields.Add(new KeyValuePair("format", "text")); + var normalizedSourceLanguage = AiTranslationService.NormalizeBloomLanguageTag( + sourceLanguageTag + ); + if (!string.IsNullOrWhiteSpace(normalizedSourceLanguage)) + { + fields.Add(new KeyValuePair("source", normalizedSourceLanguage)); + } + + using var request = new HttpRequestMessage(HttpMethod.Post, kTranslateEndpoint) + { + Content = new FormUrlEncodedContent(fields), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await httpClient.SendAsync(request, ct); + var responseContent = await response.Content.ReadAsStringAsync(); + AiTranslationProviderHelpers.EnsureSuccess( + response, + responseContent, + "Google Translate" + ); + + var responseJson = JObject.Parse(responseContent); + var translations = responseJson["data"]?["translations"] as JArray; + if (translations == null || translations.Count != segments.Length) + { + throw new InvalidOperationException( + $"Google Translate returned {translations?.Count ?? 0} translations for {segments.Length} segments." + ); + } + + return translations + .Select(t => + WebUtility.HtmlDecode(t["translatedText"]?.Value() ?? string.Empty) + ) + .ToArray(); + } + + private static void EnsureCredentials(AiTranslationEngineSettings engine) + { + if (string.IsNullOrWhiteSpace(engine.ServiceAccountEmail)) + { + throw new InvalidOperationException( + "Set a Google service account email in Collection Settings > AI Source Bubbles." + ); + } + if (string.IsNullOrWhiteSpace(engine.PrivateKey)) + { + throw new InvalidOperationException( + "Set a Google service account private key in Collection Settings > AI Source Bubbles." + ); + } + } + + private static async Task GetAccessTokenAsync( + AiTranslationEngineSettings engine, + HttpClient httpClient, + CancellationToken ct + ) + { + var now = DateTimeOffset.UtcNow; + var jwtHeader = AiTranslationProviderHelpers.Base64UrlEncode( + Encoding.UTF8.GetBytes( + JsonConvert.SerializeObject(new { alg = "RS256", typ = "JWT" }) + ) + ); + var jwtPayload = AiTranslationProviderHelpers.Base64UrlEncode( + Encoding.UTF8.GetBytes( + JsonConvert.SerializeObject( + new + { + iss = engine.ServiceAccountEmail, + scope = kScope, + aud = kTokenEndpoint, + iat = now.ToUnixTimeSeconds(), + exp = now.AddMinutes(59).ToUnixTimeSeconds(), + } + ) + ) + ); + var signingInput = $"{jwtHeader}.{jwtPayload}"; + var signedJwt = $"{signingInput}.{SignJwt(signingInput, engine.PrivateKey)}"; + + using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, kTokenEndpoint) + { + Content = new FormUrlEncodedContent( + new[] + { + new KeyValuePair( + "grant_type", + "urn:ietf:params:oauth:grant-type:jwt-bearer" + ), + new KeyValuePair("assertion", signedJwt), + } + ), + }; + + using var tokenResponse = await httpClient.SendAsync(tokenRequest, ct); + var tokenContent = await tokenResponse.Content.ReadAsStringAsync(); + AiTranslationProviderHelpers.EnsureSuccess(tokenResponse, tokenContent, "Google OAuth"); + + var tokenJson = JObject.Parse(tokenContent); + var accessToken = tokenJson["access_token"]?.Value(); + if (string.IsNullOrWhiteSpace(accessToken)) + { + throw new InvalidOperationException("Google OAuth returned no access token."); + } + + return accessToken; + } + + private static string SignJwt(string signingInput, string privateKey) + { + using var rsa = RSA.Create(); + rsa.ImportFromPem( + AiTranslationProviderHelpers.NormalizeGooglePrivateKey(privateKey).ToCharArray() + ); + var signature = rsa.SignData( + Encoding.UTF8.GetBytes(signingInput), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1 + ); + return AiTranslationProviderHelpers.Base64UrlEncode(signature); + } + } +} diff --git a/src/BloomExe/AiTranslation/IAiTranslationProvider.cs b/src/BloomExe/AiTranslation/IAiTranslationProvider.cs new file mode 100644 index 000000000000..f175dea4e9e3 --- /dev/null +++ b/src/BloomExe/AiTranslation/IAiTranslationProvider.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace Bloom.AiTranslation +{ + /// + /// Option surfaced to the Collection Settings target-language picker. + /// + public class AiTranslationTargetLanguageOption + { + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("label")] + public string Label { get; set; } + + /// + /// The ids of the (enabled) providers that were found to support this target language. + /// + [JsonProperty("providerIds")] + public List ProviderIds { get; set; } = new List(); + } + + /// + /// Contract implemented by each AI translation backend (DeepL, Google, Alpha2, ...). All + /// translation is batched: callers pass an array of text segments and get back translations + /// in the same order, chunked to fit this provider's per-request limits. + /// + internal interface IAiTranslationProvider + { + string ProviderId { get; } + + /// + /// Max number of text segments per HTTP request (DeepL: 50, Google: 128). + /// + int MaxSegmentsPerRequest { get; } + + /// + /// Conservative max total UTF-8 bytes of segment text per request (DeepL: 120_000, Google: 100_000). + /// + int MaxRequestBytes { get; } + + Task> GetSupportedTargetLanguagesAsync( + AiTranslationEngineSettings engine, + HttpClient httpClient, + CancellationToken ct + ); + + /// + /// Translates segments preserving order and count; implementations must throw if the + /// service returns a different number of translations than segments. + /// + Task TranslateBatchAsync( + AiTranslationEngineSettings engine, + string[] segments, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient, + CancellationToken ct + ); + } +} diff --git a/src/BloomExe/Book/Book.cs b/src/BloomExe/Book/Book.cs index 7120900dec14..0d47998bc5a4 100644 --- a/src/BloomExe/Book/Book.cs +++ b/src/BloomExe/Book/Book.cs @@ -6141,77 +6141,5 @@ public bool IsPlayground ?? false; } } - - public void AITranslate() - { - var groups = this - .Storage.Dom.RawDom.SafeSelectNodes( - "//div[contains(@class, 'bloom-translationGroup')]" - ) - .Cast(); - var sourceLang = "en"; - var targetLang = "es"; - foreach (var group in groups) - { - var node = group.SelectSingleNode($"div[@lang = '{targetLang}-x-ai']"); - if (node != null) - { - // for now, delete it - group.RemoveChild(node); - } - var sourceEditable = group.SelectSingleNode($"div[@lang = '{sourceLang}']"); - if (sourceEditable != null) - { - var sourceText = sourceEditable.InnerText; - var editable = this.Storage.Dom.RawDom.CreateElement("div"); - editable.SetAttribute("class", "bloom-editable"); - editable.SetAttribute("lang", $"{targetLang}-x-ai"); - group.AppendChild(editable); - Translate( - sourceText, - sourceLang, - targetLang, - result => editable.InnerText = result - ); - } - } - } - - private void Translate( - string sourceText, - string sourceLang, - string targetLang, - Action setValue - ) - { - var client = new HttpClient(); - - //try - //{ - // var airequest = new - // { - // text = new[] { sourceText }, - // source_lang = sourceLang, - // target_lang = targetLang - // }; - // // make a request to the DeepL API using post - // var request = new HttpRequestMessage(HttpMethod.Post, "https://api-free.deepl.com/v2/translate"); - // // set content type to json - // request.Content = new StringContent(JsonConvert.SerializeObject(airequest), Encoding.UTF8, "application/json"); - // request.Headers.Add("Authorization", "DeepL-Auth-Key 8e7a9639-542f-ee8a-d204-a99415a4755c:fx"); - - // var response = client.SendAsync(request).Result; - // // wait for the response - - // response.EnsureSuccessStatusCode(); - - // var responseContent = await airequest.Content.ReadAsStringAsync(); - // dynamic responseData = Newtonsoft.Json.JsonConvert.DeserializeObject(responseContent); - // string translatedText = responseData.translations[0].text; - - // return translatedText; - //} - // } - } } } diff --git a/src/BloomExe/Book/RuntimeInformationInjector.cs b/src/BloomExe/Book/RuntimeInformationInjector.cs index 2c52fb44aa52..48c0c9466bcb 100644 --- a/src/BloomExe/Book/RuntimeInformationInjector.cs +++ b/src/BloomExe/Book/RuntimeInformationInjector.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Xml; -using Bloom.AiSourceBubbles; +using Bloom.AiTranslation; using Bloom.Collection; using Bloom.Properties; using Bloom.SafeXml; @@ -496,35 +496,16 @@ IFileLocator fileLocator bookData.CollectionSettings.Subscription, FeatureName.AiSourceBubbles ); - var aiSourceBubblesProviderId = AiSourceBubblesService.NormalizeProviderId( - bookData.CollectionSettings.AiSourceBubblesProviderId - ); - var aiSourceBubblesTargetLanguageTag = AiSourceBubblesService.NormalizeBloomLanguageTag( - bookData.CollectionSettings.AiSourceBubblesTargetLanguageTag - ); - var aiSourceBubblesConfigurationFingerprint = - AiSourceBubblesService.GetConfigurationFingerprint(bookData.CollectionSettings); - var aiSourceBubblesConfigurationValid = - bookData.CollectionSettings.AiSourceBubblesLastValidationSucceeded - && String.Equals( - bookData.CollectionSettings.AiSourceBubblesValidatedConfigurationFingerprint, - aiSourceBubblesConfigurationFingerprint, - StringComparison.Ordinal - ); + // At least one engine must be enabled and validated, with that validation still + // matching its current configuration (provider, target language, credentials). + var anyEngineReady = AiTranslationService + .GetActiveEngines(bookData.CollectionSettings) + .Any(); d.Add( "allowAiSourceBubbles", aiSourceBubblesFeatureStatus.Visible && aiSourceBubblesFeatureStatus.Enabled - && aiSourceBubblesConfigurationValid - ); - d.Add("aiSourceBubblesProvider", aiSourceBubblesProviderId); - d.Add("aiSourceBubblesTargetLanguageTag", aiSourceBubblesTargetLanguageTag); - d.Add( - "aiSourceBubblesLanguageTag", - AiSourceBubblesService.GetAiLanguageTag( - aiSourceBubblesTargetLanguageTag, - aiSourceBubblesProviderId - ) + && anyEngineReady ); element.InnerText = String.Format( diff --git a/src/BloomExe/Collection/CollectionSettings.cs b/src/BloomExe/Collection/CollectionSettings.cs index 3cf8f921599b..86c315f98ce3 100644 --- a/src/BloomExe/Collection/CollectionSettings.cs +++ b/src/BloomExe/Collection/CollectionSettings.cs @@ -7,6 +7,7 @@ using System.Text; using System.Xml.Linq; using System.Xml.Serialization; +using Bloom.AiTranslation; using Bloom.Api; using Bloom.Book; using Bloom.MiscUI; @@ -72,14 +73,43 @@ public class CollectionSettings // if this is null, relevant code uses the default, so we don't have to initialize it here public string BadgeQrCodeLabel; - public string AiSourceBubblesProviderId = "deepl"; - public string AiSourceBubblesTargetLanguageTag = ""; - public string AiSourceBubblesDeepLApiKey = ""; - public string AiSourceBubblesGoogleServiceAccountEmail = ""; - public string AiSourceBubblesGooglePrivateKey = ""; - public string AiSourceBubblesValidatedConfigurationFingerprint = ""; - public bool AiSourceBubblesLastValidationSucceeded = false; - public string AiSourceBubblesLastValidationMessage = ""; + /// + /// The provider ids for AI translation, in the fixed order used for settings + /// persistence and the settings UI. AiTranslationEngines always has exactly one + /// entry for each of these, in this order; see EnsureAiTranslationEngines(). + /// + public static readonly string[] kAiTranslationProviderIds = { "deepl", "google", "alpha2" }; + + /// + /// The single target language shared by all AI translation engines. + /// + public string AiTranslationTargetLanguageTag = ""; + + /// + /// Per-engine AI translation configuration. Normalized (see + /// EnsureAiTranslationEngines()) to contain exactly one entry for each provider in + /// kAiTranslationProviderIds, in that order. + /// + public List AiTranslationEngines = + new List(); + + /// + /// Ensures AiTranslationEngines contains exactly one entry for each provider in + /// kAiTranslationProviderIds, in that order, preserving any existing settings for + /// providers already present and adding defaults for any that are missing. + /// + public void EnsureAiTranslationEngines() + { + var normalized = new List(); + foreach (var providerId in kAiTranslationProviderIds) + { + var existing = AiTranslationEngines?.Find(e => e.ProviderId == providerId); + normalized.Add( + existing ?? new AiTranslationEngineSettings { ProviderId = providerId } + ); + } + AiTranslationEngines = normalized; + } public static readonly Dictionary CssNumberStylesToCultureOrDigits = new Dictionary() @@ -207,6 +237,7 @@ public CollectionSettings() AudioRecordingMode = TalkingBookApi.AudioRecordingMode.Sentence; AudioRecordingTrimEndMilliseconds = kDefaultAudioRecordingTrimEndMilliseconds; BooksOnWebGoal = kDefaultBooksOnWebGoal; + EnsureAiTranslationEngines(); } public static void CreateNewCollection(NewCollectionSettings collectionInfo) @@ -409,38 +440,32 @@ public void Save() xml.Add(BulkPublishBloomPubSettings.ToXElement()); xml.Add(new XElement("ShowBlorgLanguageQrCode", ShowBlorgLanguageQrCode)); xml.Add(new XElement("BadgeQrCodeLabel", BadgeQrCodeLabel)); - xml.Add(new XElement("AiSourceBubblesProviderId", AiSourceBubblesProviderId)); - xml.Add( - new XElement("AiSourceBubblesTargetLanguageTag", AiSourceBubblesTargetLanguageTag) - ); - xml.Add(new XElement("AiSourceBubblesDeepLApiKey", AiSourceBubblesDeepLApiKey)); - xml.Add( - new XElement( - "AiSourceBubblesGoogleServiceAccountEmail", - AiSourceBubblesGoogleServiceAccountEmail - ) - ); - xml.Add( - new XElement("AiSourceBubblesGooglePrivateKey", AiSourceBubblesGooglePrivateKey) - ); - xml.Add( - new XElement( - "AiSourceBubblesValidatedConfigurationFingerprint", - AiSourceBubblesValidatedConfigurationFingerprint - ) - ); - xml.Add( - new XElement( - "AiSourceBubblesLastValidationSucceeded", - AiSourceBubblesLastValidationSucceeded - ) - ); - xml.Add( - new XElement( - "AiSourceBubblesLastValidationMessage", - AiSourceBubblesLastValidationMessage - ) - ); + xml.Add(new XElement("AiTranslationTargetLanguageTag", AiTranslationTargetLanguageTag)); + EnsureAiTranslationEngines(); + foreach (var engine in AiTranslationEngines) + { + var engineElement = new XElement("AiTranslationEngine"); + engineElement.Add(new XAttribute("id", engine.ProviderId)); + engineElement.Add(new XAttribute("enabled", engine.Enabled)); + engineElement.Add( + new XAttribute("validationSucceeded", engine.LastValidationSucceeded) + ); + engineElement.Add( + new XAttribute( + "validatedFingerprint", + engine.ValidatedConfigurationFingerprint ?? "" + ) + ); + engineElement.Add(new XElement("ApiKey", engine.ApiKey ?? "")); + engineElement.Add( + new XElement("ServiceAccountEmail", engine.ServiceAccountEmail ?? "") + ); + engineElement.Add(new XElement("PrivateKey", engine.PrivateKey ?? "")); + engineElement.Add( + new XElement("ValidationMessage", engine.LastValidationMessage ?? "") + ); + xml.Add(engineElement); + } RobustIO.SaveXElement(xml, SettingsFilePath); // Color palette settings are stored in a separate Json file @@ -715,38 +740,38 @@ public void Load() ShowBlorgLanguageQrCode = ReadBoolean(xml, "ShowBlorgLanguageQrCode", true); BadgeQrCodeLabel = ReadString(xml, "BadgeQrCodeLabel", ""); - AiSourceBubblesProviderId = ReadString(xml, "AiSourceBubblesProviderId", "deepl"); - AiSourceBubblesTargetLanguageTag = ReadString( - xml, - "AiSourceBubblesTargetLanguageTag", - "" - ); - AiSourceBubblesDeepLApiKey = ReadString(xml, "AiSourceBubblesDeepLApiKey", ""); - AiSourceBubblesGoogleServiceAccountEmail = ReadString( + AiTranslationTargetLanguageTag = ReadString( xml, - "AiSourceBubblesGoogleServiceAccountEmail", - "" - ); - AiSourceBubblesGooglePrivateKey = ReadString( - xml, - "AiSourceBubblesGooglePrivateKey", - "" - ); - AiSourceBubblesValidatedConfigurationFingerprint = ReadString( - xml, - "AiSourceBubblesValidatedConfigurationFingerprint", - "" - ); - AiSourceBubblesLastValidationSucceeded = ReadBoolean( - xml, - "AiSourceBubblesLastValidationSucceeded", - false - ); - AiSourceBubblesLastValidationMessage = ReadString( - xml, - "AiSourceBubblesLastValidationMessage", + "AiTranslationTargetLanguageTag", "" ); + // This is an unreleased experiment, so there is no migration from the old + // single-provider settings: any old AiTranslation* elements are just ignored. + AiTranslationEngines = xml.Descendants("AiTranslationEngine") + .Select(engineElement => + { + bool.TryParse(engineElement.Attribute("enabled")?.Value, out var enabled); + bool.TryParse( + engineElement.Attribute("validationSucceeded")?.Value, + out var validationSucceeded + ); + return new AiTranslationEngineSettings + { + ProviderId = engineElement.Attribute("id")?.Value ?? "", + Enabled = enabled, + LastValidationSucceeded = validationSucceeded, + ValidatedConfigurationFingerprint = + engineElement.Attribute("validatedFingerprint")?.Value ?? "", + ApiKey = engineElement.Element("ApiKey")?.Value ?? "", + ServiceAccountEmail = + engineElement.Element("ServiceAccountEmail")?.Value ?? "", + PrivateKey = engineElement.Element("PrivateKey")?.Value ?? "", + LastValidationMessage = + engineElement.Element("ValidationMessage")?.Value ?? "", + }; + }) + .ToList(); + EnsureAiTranslationEngines(); LoadDictionary(xml, "Palette", ColorPalettes); } diff --git a/src/BloomExe/Collection/CollectionSettingsDialog.cs b/src/BloomExe/Collection/CollectionSettingsDialog.cs index c12da5a3ed2c..e2366bceef92 100644 --- a/src/BloomExe/Collection/CollectionSettingsDialog.cs +++ b/src/BloomExe/Collection/CollectionSettingsDialog.cs @@ -1,8 +1,9 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Windows.Forms; -using Bloom.AiSourceBubbles; +using Bloom.AiTranslation; using Bloom.Book; using Bloom.MiscUI; using Bloom.Properties; @@ -53,14 +54,8 @@ public string PendingDefaultBookshelf internal bool PendingAllowTeamCollection; internal bool PendingAllowAppBuilder; internal bool PendingAllowAiSourceBubbles; - internal string PendingAiSourceBubblesProviderId; - internal string PendingAiSourceBubblesTargetLanguageTag; - internal string PendingAiSourceBubblesDeepLApiKey; - internal string PendingAiSourceBubblesGoogleServiceAccountEmail; - internal string PendingAiSourceBubblesGooglePrivateKey; - internal string PendingAiSourceBubblesValidatedConfigurationFingerprint; - internal bool PendingAiSourceBubblesLastValidationSucceeded; - internal string PendingAiSourceBubblesLastValidationMessage; + public string PendingAiTranslationTargetLanguageTag; + public List PendingAiTranslationEngines; internal bool AllowTeamCollectionOptionEnabled = false; // "Internal" so CollectionSettingsApi can update these. @@ -134,22 +129,12 @@ XMatterPackFinder xmatterPackFinder PendingAllowAiSourceBubbles = ExperimentalFeatures.IsFeatureEnabled( ExperimentalFeatures.kAiSourceBubbles ); - PendingAiSourceBubblesProviderId = AiSourceBubblesService.NormalizeProviderId( - _collectionSettings.AiSourceBubblesProviderId - ); - PendingAiSourceBubblesTargetLanguageTag = - _collectionSettings.AiSourceBubblesTargetLanguageTag; - PendingAiSourceBubblesDeepLApiKey = _collectionSettings.AiSourceBubblesDeepLApiKey; - PendingAiSourceBubblesGoogleServiceAccountEmail = - _collectionSettings.AiSourceBubblesGoogleServiceAccountEmail; - PendingAiSourceBubblesGooglePrivateKey = - _collectionSettings.AiSourceBubblesGooglePrivateKey; - PendingAiSourceBubblesValidatedConfigurationFingerprint = - _collectionSettings.AiSourceBubblesValidatedConfigurationFingerprint; - PendingAiSourceBubblesLastValidationSucceeded = - _collectionSettings.AiSourceBubblesLastValidationSucceeded; - PendingAiSourceBubblesLastValidationMessage = - _collectionSettings.AiSourceBubblesLastValidationMessage; + PendingAiTranslationTargetLanguageTag = + _collectionSettings.AiTranslationTargetLanguageTag; + _collectionSettings.EnsureAiTranslationEngines(); + PendingAiTranslationEngines = _collectionSettings + .AiTranslationEngines.Select(engine => engine.Clone()) + .ToList(); if ( !ExperimentalFeatures.IsFeatureEnabled(ExperimentalFeatures.kTeamCollections) @@ -440,21 +425,11 @@ private void _okButton_Click(object sender, EventArgs e) UpdateTeamCollectionAllowed(); UpdateAppBuilderAllowed(); UpdateAiSourceBubblesAllowed(); - _collectionSettings.AiSourceBubblesProviderId = - AiSourceBubblesService.NormalizeProviderId(PendingAiSourceBubblesProviderId); - _collectionSettings.AiSourceBubblesTargetLanguageTag = - PendingAiSourceBubblesTargetLanguageTag; - _collectionSettings.AiSourceBubblesDeepLApiKey = PendingAiSourceBubblesDeepLApiKey; - _collectionSettings.AiSourceBubblesGoogleServiceAccountEmail = - PendingAiSourceBubblesGoogleServiceAccountEmail; - _collectionSettings.AiSourceBubblesGooglePrivateKey = - PendingAiSourceBubblesGooglePrivateKey; - _collectionSettings.AiSourceBubblesValidatedConfigurationFingerprint = - PendingAiSourceBubblesValidatedConfigurationFingerprint; - _collectionSettings.AiSourceBubblesLastValidationSucceeded = - PendingAiSourceBubblesLastValidationSucceeded; - _collectionSettings.AiSourceBubblesLastValidationMessage = - PendingAiSourceBubblesLastValidationMessage; + _collectionSettings.AiTranslationTargetLanguageTag = + PendingAiTranslationTargetLanguageTag; + _collectionSettings.AiTranslationEngines = PendingAiTranslationEngines + .Select(engine => engine.Clone()) + .ToList(); _collectionSettings.Country = _countryText.Text.Trim(); _collectionSettings.Province = _provinceText.Text.Trim(); diff --git a/src/BloomExe/Edit/EditingModel.cs b/src/BloomExe/Edit/EditingModel.cs index d2f6380aaad4..3e5ef9dbf809 100644 --- a/src/BloomExe/Edit/EditingModel.cs +++ b/src/BloomExe/Edit/EditingModel.cs @@ -9,6 +9,7 @@ using System.Text; using System.Threading.Tasks; using System.Windows.Forms; +using Bloom.AiTranslation; using Bloom.Api; using Bloom.Book; using Bloom.Collection; @@ -55,6 +56,7 @@ public class EditingModel private IPage _previouslySelectedPage; private BloomServer _server; private readonly BloomWebSocketServer _webSocketServer; + private readonly AiTranslationBookUpdater _aiTranslationBookUpdater; internal IPage PageChangingLayout; // used to save the page on which the choose different layout command was invoked while the dialog is active. // This event fires after the EditingModel has finished responding to a PageSelection change. @@ -105,7 +107,8 @@ public EditingModel( CollectionSettings collectionSettings, BloomServer server, BloomWebSocketServer webSocketServer, - ITemplateFinder sourceCollectionsList + ITemplateFinder sourceCollectionsList, + AiTranslationBookUpdater aiTranslationBookUpdater ) { _bookSelection = bookSelection; @@ -114,6 +117,7 @@ ITemplateFinder sourceCollectionsList _server = server; _webSocketServer = webSocketServer; _sourceCollectionsList = sourceCollectionsList; + _aiTranslationBookUpdater = aiTranslationBookUpdater; _stateMachine = new EditingStateMachine( // navigate, @@ -950,9 +954,14 @@ public void OnBecomeVisible() CurrentBook.SetMultilingualContentLanguages(contentLanguages); CurrentBook.PrepareForEditing(); - // kick off a task to call Translate() on the book - //new Task(() => CurrentBook.AITranslate()).Start(); - //CurrentBook.AITranslate(); + // Scan the book for AI Source Bubbles translation work (missing/stale translations + // for any active engine, or stale AI content needing cleanup) and, if any is found, + // block here -- showing a modal progress dialog -- until it is translated, applied, + // and saved, or the user cancels. This must happen before the page below is loaded, so + // the user edits pages that already reflect any newly-applied AI translations. When + // nothing needs doing (the normal case once a book is up to date), this returns + // immediately with no dialog and no delay. + _aiTranslationBookUpdater.UpdateBookIfNeeded(CurrentBook); } _currentlyDisplayedBook = CurrentBook; diff --git a/src/BloomExe/ProjectContext.cs b/src/BloomExe/ProjectContext.cs index 9708ea97a60e..4fd43e56100f 100644 --- a/src/BloomExe/ProjectContext.cs +++ b/src/BloomExe/ProjectContext.cs @@ -6,7 +6,7 @@ using System.Reflection; using System.Windows.Forms; using Autofac; -using Bloom.AiSourceBubbles; +using Bloom.AiTranslation; using Bloom.Api; using Bloom.Book; using Bloom.Collection; @@ -162,8 +162,8 @@ IContainer parentContainer typeof(RabPublishApi), typeof(AccessibilityCheckApi), typeof(CollectionSettingsApi), - typeof(AiSourceBubblesApi), - typeof(AiSourceBubblesService), + typeof(AiTranslationService), + typeof(AiTranslationBookUpdater), typeof(SubscriptionSettingsEditorApi), typeof(FeatureStatusApi), typeof(CollectionTabView), @@ -428,7 +428,6 @@ IContainer parentContainer _scope.Resolve().RegisterWithApiHandler(server.ApiHandler); _scope.Resolve().RegisterWithApiHandler(server.ApiHandler); _scope.Resolve().RegisterWithApiHandler(server.ApiHandler); - _scope.Resolve().RegisterWithApiHandler(server.ApiHandler); _scope .Resolve() .RegisterWithApiHandler(server.ApiHandler); diff --git a/src/BloomExe/web/controllers/AiSourceBubblesApi.cs b/src/BloomExe/web/controllers/AiSourceBubblesApi.cs deleted file mode 100644 index 7f6da1e165cc..000000000000 --- a/src/BloomExe/web/controllers/AiSourceBubblesApi.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Net; -using System.Threading.Tasks; -using Bloom.AiSourceBubbles; -using Bloom.Api; -using Newtonsoft.Json.Linq; - -namespace Bloom.web.controllers -{ - public class AiSourceBubblesApi - { - private const string kApiUrlPart = "aiSourceBubbles/"; - private readonly AiSourceBubblesService _aiSourceBubblesService; - - public AiSourceBubblesApi(AiSourceBubblesService aiSourceBubblesService) - { - _aiSourceBubblesService = aiSourceBubblesService; - } - - /// - /// Registers API endpoints for AI Source Bubbles. - /// - public void RegisterWithApiHandler(BloomApiHandler apiHandler) - { - apiHandler.RegisterAsyncEndpointHandler( - kApiUrlPart + "translate", - HandleTranslateAsync, - false, - false - ); - } - - private async Task HandleTranslateAsync(ApiRequest request) - { - if (request.HttpMethod != HttpMethods.Post) - { - request.Failed(HttpStatusCode.MethodNotAllowed, "Only POST is supported."); - return; - } - - try - { - var requestJson = JObject.Parse(request.RequiredPostJson()); - var response = await _aiSourceBubblesService.TranslateAsync( - new AiSourceBubblesTranslateRequest - { - SourceText = requestJson["sourceText"]?.Value(), - SourceLanguageTag = requestJson["sourceLanguageTag"]?.Value(), - } - ); - request.ReplyWithJson(response); - } - catch (ArgumentException e) - { - request.Failed(HttpStatusCode.BadRequest, e.Message); - } - catch (InvalidOperationException e) - { - request.Failed(HttpStatusCode.BadRequest, e.Message); - } - } - } -} diff --git a/src/BloomExe/web/controllers/BookCommandsApi.cs b/src/BloomExe/web/controllers/BookCommandsApi.cs index 09b81828c49b..c481ed0a9a19 100644 --- a/src/BloomExe/web/controllers/BookCommandsApi.cs +++ b/src/BloomExe/web/controllers/BookCommandsApi.cs @@ -8,6 +8,7 @@ using System.Text; using System.Threading.Tasks; using System.Windows.Forms; +using Bloom.AiTranslation; using Bloom.Api; using Bloom.Book; using Bloom.Collection; @@ -181,6 +182,19 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) }, true ); + apiHandler.RegisterEndpointHandler( + "bookCommand/removeAiSourceTranslations", + (request) => + { + var book = GetBookObjectFromPost(request); + HandleRemoveAiSourceTranslations(book); + request.PostSucceeded(); + }, + // On the UI thread (it shows a modal confirmation dialog) but not sync: the dialog + // makes further API requests that would deadlock if this endpoint held the lock. + true, + false + ); } public void RequestButtonLabelUpdate(string collectionPath, string id) @@ -517,6 +531,35 @@ private void HandleBringBookUpToDate(Book.Book book) } } + /// + /// Removes every AI-generated source translation from the book, saves it, and shows a + /// confirmation dialog. Reached only from the (save-permission-gated) "Remove AI source + /// translations" collection-tab menu item. + /// + private void HandleRemoveAiSourceTranslations(Book.Book book) + { + // RemoveAllAiDivs finds AI content purely by its "-x-ai" language marker, so the + // scanner needs no engine/target-language/source-priority context here. + var scanner = new AiTranslationBookScanner( + book.OurHtmlDom, + targetLanguageTag: null, + enabledEngines: null, + sourceLanguagePriorities: null + ); + var removedCount = scanner.RemoveAllAiDivs(); + book.Save(); + Logger.WriteEvent( + $"AI translation: removed {removedCount} AI source translation div(s) at user request." + ); + + BloomMessageBox.ShowInfo( + LocalizationManager.GetString( + "CollectionTab.BookMenu.RemovedAiSourceTranslations", + "Bloom removed the AI source translations from this book." + ) + ); + } + private BookInfo GetBookInfoFromPost(ApiRequest request) { var bookId = request.RequiredPostString(); diff --git a/src/BloomExe/web/controllers/CollectionSettingsApi.cs b/src/BloomExe/web/controllers/CollectionSettingsApi.cs index f3cc28641d1e..a6e1b7363193 100644 --- a/src/BloomExe/web/controllers/CollectionSettingsApi.cs +++ b/src/BloomExe/web/controllers/CollectionSettingsApi.cs @@ -2,12 +2,14 @@ using System.Collections.Generic; using System.Dynamic; using System.Globalization; +using System.Linq; using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Text; +using System.Threading; using System.Threading.Tasks; -using Bloom.AiSourceBubbles; +using Bloom.AiTranslation; using Bloom.Api; using Bloom.Book; using Bloom.Collection; @@ -96,14 +98,14 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) true ); apiHandler.RegisterAsyncEndpointHandler( - kApiUrlPart + "validateAiSourceBubbles", - HandleValidateAiSourceBubblesAsync, + kApiUrlPart + "validateAiTranslationEngine", + HandleValidateAiTranslationEngineAsync, false, true ); apiHandler.RegisterAsyncEndpointHandler( - kApiUrlPart + "aiSourceBubblesSupportedLanguages", - HandleGetAiSourceBubblesSupportedLanguagesAsync, + kApiUrlPart + "aiTranslationSupportedLanguages", + HandleGetAiTranslationSupportedLanguagesAsync, false, true ); @@ -322,7 +324,6 @@ private object GetAdvancedSettingsData() var isAutoUpdateSupported = dialog?.ShowAutomaticallyUpdateOption ?? CollectionSettingsDialog.AutoUpdateSupportedOnThisPlatform; - var aiSourceBubblesValidation = GetAiSourceBubblesValidationState(dialog); return new { values = new @@ -343,18 +344,7 @@ private object GetAdvancedSettingsData() ?? ExperimentalFeatures.IsFeatureEnabled( ExperimentalFeatures.kAiSourceBubbles ), - aiSourceBubblesProvider = AiSourceBubblesService.NormalizeProviderId( - dialog?.PendingAiSourceBubblesProviderId - ?? _collectionSettings.AiSourceBubblesProviderId - ), - aiSourceBubblesTargetLanguageTag = dialog?.PendingAiSourceBubblesTargetLanguageTag - ?? _collectionSettings.AiSourceBubblesTargetLanguageTag, - aiSourceBubblesDeepLApiKey = dialog?.PendingAiSourceBubblesDeepLApiKey - ?? _collectionSettings.AiSourceBubblesDeepLApiKey, - aiSourceBubblesGoogleServiceAccountEmail = dialog?.PendingAiSourceBubblesGoogleServiceAccountEmail - ?? _collectionSettings.AiSourceBubblesGoogleServiceAccountEmail, - aiSourceBubblesGooglePrivateKey = dialog?.PendingAiSourceBubblesGooglePrivateKey - ?? _collectionSettings.AiSourceBubblesGooglePrivateKey, + aiTranslation = GetAiTranslationData(dialog), showQrCode = dialog?.PendingShowQrCode ?? _collectionSettings.ShowBlorgLanguageQrCode, qrcodeCaption = dialog?.PendingBadgeQrCodeCaption @@ -364,97 +354,69 @@ private object GetAdvancedSettingsData() showExperimentalBookSourcesOption = dialog?.ShowExperimentalBookSourcesOption ?? false, allowTeamCollectionEnabled = dialog?.AllowTeamCollectionOptionEnabled ?? true, - aiSourceBubblesValidation, }; } - private object GetAiSourceBubblesValidationState(CollectionSettingsDialog dialog) + /// + /// Builds the aiTranslation payload sent to the client: the shared target language and, + /// always in deepl/google/alpha2 order, each engine's configuration plus whether its + /// stored validation is still up to date with that configuration. + /// + private object GetAiTranslationData(CollectionSettingsDialog dialog) { - var currentSettings = GetAiSourceBubblesSettings(dialog); - var currentFingerprint = AiSourceBubblesService.GetConfigurationFingerprint( - currentSettings - ); - var validatedFingerprint = - dialog?.PendingAiSourceBubblesValidatedConfigurationFingerprint - ?? _collectionSettings.AiSourceBubblesValidatedConfigurationFingerprint; - var succeeded = - dialog?.PendingAiSourceBubblesLastValidationSucceeded - ?? _collectionSettings.AiSourceBubblesLastValidationSucceeded; - var message = - dialog?.PendingAiSourceBubblesLastValidationMessage - ?? _collectionSettings.AiSourceBubblesLastValidationMessage; - var isCurrent = String.Equals( - currentFingerprint, - validatedFingerprint, - StringComparison.Ordinal - ); + var targetLanguageTag = + dialog?.PendingAiTranslationTargetLanguageTag + ?? _collectionSettings.AiTranslationTargetLanguageTag; + if (dialog == null) + _collectionSettings.EnsureAiTranslationEngines(); + var engines = + dialog?.PendingAiTranslationEngines ?? _collectionSettings.AiTranslationEngines; return new { - currentFingerprint, - validatedFingerprint = isCurrent ? validatedFingerprint : String.Empty, - succeeded = isCurrent && succeeded, - message = isCurrent ? message : String.Empty, - }; - } - - private CollectionSettings GetAiSourceBubblesSettings(CollectionSettingsDialog dialog) - { - return new CollectionSettings - { - Subscription = _collectionSettings.Subscription, - AiSourceBubblesProviderId = - dialog?.PendingAiSourceBubblesProviderId - ?? _collectionSettings.AiSourceBubblesProviderId, - AiSourceBubblesTargetLanguageTag = - dialog?.PendingAiSourceBubblesTargetLanguageTag - ?? _collectionSettings.AiSourceBubblesTargetLanguageTag, - AiSourceBubblesDeepLApiKey = - dialog?.PendingAiSourceBubblesDeepLApiKey - ?? _collectionSettings.AiSourceBubblesDeepLApiKey, - AiSourceBubblesGoogleServiceAccountEmail = - dialog?.PendingAiSourceBubblesGoogleServiceAccountEmail - ?? _collectionSettings.AiSourceBubblesGoogleServiceAccountEmail, - AiSourceBubblesGooglePrivateKey = - dialog?.PendingAiSourceBubblesGooglePrivateKey - ?? _collectionSettings.AiSourceBubblesGooglePrivateKey, + targetLanguageTag, + engines = engines + .Select(engine => + (object) + new + { + providerId = engine.ProviderId, + enabled = engine.Enabled, + apiKey = engine.ApiKey ?? "", + serviceAccountEmail = engine.ServiceAccountEmail ?? "", + privateKey = engine.PrivateKey ?? "", + validation = new + { + succeeded = engine.LastValidationSucceeded, + message = engine.LastValidationMessage ?? "", + upToDate = String.Equals( + engine.ValidatedConfigurationFingerprint, + AiTranslationService.GetEngineFingerprint( + engine, + targetLanguageTag + ), + StringComparison.Ordinal + ), + }, + } + ) + .ToArray(), }; } - private static void InvalidateAiSourceBubblesValidation(CollectionSettingsDialog dialog) + private static void InvalidateEngineValidation(AiTranslationEngineSettings engine) { - var pendingSettings = new CollectionSettings - { - AiSourceBubblesProviderId = dialog.PendingAiSourceBubblesProviderId, - AiSourceBubblesTargetLanguageTag = dialog.PendingAiSourceBubblesTargetLanguageTag, - AiSourceBubblesDeepLApiKey = dialog.PendingAiSourceBubblesDeepLApiKey, - AiSourceBubblesGoogleServiceAccountEmail = - dialog.PendingAiSourceBubblesGoogleServiceAccountEmail, - AiSourceBubblesGooglePrivateKey = dialog.PendingAiSourceBubblesGooglePrivateKey, - }; - var currentFingerprint = AiSourceBubblesService.GetConfigurationFingerprint( - pendingSettings - ); - if ( - String.Equals( - dialog.PendingAiSourceBubblesValidatedConfigurationFingerprint, - currentFingerprint, - StringComparison.Ordinal - ) - ) - { - return; - } - - dialog.PendingAiSourceBubblesValidatedConfigurationFingerprint = String.Empty; - dialog.PendingAiSourceBubblesLastValidationSucceeded = false; - dialog.PendingAiSourceBubblesLastValidationMessage = String.Empty; + engine.ValidatedConfigurationFingerprint = String.Empty; + engine.LastValidationSucceeded = false; + engine.LastValidationMessage = String.Empty; } /// - /// Validates the AI Source Bubbles configuration currently being edited in Collection Settings. + /// Validates one AI translation engine (identified by providerId in the post body) + /// using the PENDING settings currently being edited in Collection Settings, and stores + /// the result and fingerprint back on that pending engine. /// - private async Task HandleValidateAiSourceBubblesAsync(ApiRequest request) + private async Task HandleValidateAiTranslationEngineAsync(ApiRequest request) { if (request.HttpMethod != HttpMethods.Post) { @@ -463,64 +425,75 @@ private async Task HandleValidateAiSourceBubblesAsync(ApiRequest request) } var dialog = DialogBeingEdited; - var requestJson = request.RequiredPostJson(); - if (dialog != null && !String.IsNullOrWhiteSpace(requestJson)) + if (dialog == null) { - StoreAdvancedSettingsData(JObject.Parse(requestJson), dialog); + request.Failed( + HttpStatusCode.BadRequest, + "AI translation can only be validated while Collection Settings is open." + ); + return; } - var settings = GetAiSourceBubblesSettings(dialog); - var validationResult = new AiSourceBubblesValidationResult + var requestJson = JObject.Parse(request.RequiredPostJson()); + var providerId = AiTranslationService.NormalizeProviderId( + requestJson["providerId"]?.Value() + ); + var engine = dialog.PendingAiTranslationEngines.Single(e => e.ProviderId == providerId); + + var succeeded = false; + var message = String.Empty; + var tempSettings = new CollectionSettings { - ConfigurationFingerprint = AiSourceBubblesService.GetConfigurationFingerprint( - settings - ), - Succeeded = false, - Message = String.Empty, + Subscription = _collectionSettings.Subscription, + AiTranslationTargetLanguageTag = dialog.PendingAiTranslationTargetLanguageTag, }; try { - validationResult = await new AiSourceBubblesService( - settings - ).ValidateConfigurationAsync(); + var validationResult = await new AiTranslationService( + tempSettings + ).ValidateEngineAsync(engine, CancellationToken.None); + succeeded = validationResult.Succeeded; + message = validationResult.Message; + engine.ValidatedConfigurationFingerprint = + validationResult.ConfigurationFingerprint; } catch (ArgumentException e) { - validationResult.Message = e.Message; + message = e.Message; } catch (InvalidOperationException e) { - validationResult.Message = e.Message; + message = e.Message; } catch (HttpRequestException e) { - validationResult.Message = e.Message; + message = e.Message; } catch (CryptographicException e) { - validationResult.Message = e.Message; + message = e.Message; } catch (JsonException e) { - validationResult.Message = e.Message; + message = e.Message; } - if (dialog != null) + engine.LastValidationSucceeded = succeeded; + engine.LastValidationMessage = message; + if (!succeeded) { - dialog.PendingAiSourceBubblesValidatedConfigurationFingerprint = - validationResult.ConfigurationFingerprint; - dialog.PendingAiSourceBubblesLastValidationSucceeded = validationResult.Succeeded; - dialog.PendingAiSourceBubblesLastValidationMessage = validationResult.Message; + engine.ValidatedConfigurationFingerprint = String.Empty; } - request.ReplyWithJson(validationResult); + request.ReplyWithJson(new { succeeded, message }); } /// - /// Gets the provider-backed list of target languages for the AI Source Bubbles settings currently being edited. + /// Gets the union of provider-backed target languages across the currently-enabled + /// (pending, if Collection Settings is open) AI translation engines. /// - private async Task HandleGetAiSourceBubblesSupportedLanguagesAsync(ApiRequest request) + private async Task HandleGetAiTranslationSupportedLanguagesAsync(ApiRequest request) { if (request.HttpMethod != HttpMethods.Post) { @@ -529,18 +502,32 @@ private async Task HandleGetAiSourceBubblesSupportedLanguagesAsync(ApiRequest re } var dialog = DialogBeingEdited; - var requestJson = request.RequiredPostJson(); - if (dialog != null && !String.IsNullOrWhiteSpace(requestJson)) + if (dialog == null) + _collectionSettings.EnsureAiTranslationEngines(); + var tempSettings = new CollectionSettings { - StoreAdvancedSettingsData(JObject.Parse(requestJson), dialog); - } + Subscription = _collectionSettings.Subscription, + AiTranslationTargetLanguageTag = + dialog?.PendingAiTranslationTargetLanguageTag + ?? _collectionSettings.AiTranslationTargetLanguageTag, + AiTranslationEngines = ( + dialog?.PendingAiTranslationEngines ?? _collectionSettings.AiTranslationEngines + ) + .Select(engine => engine.Clone()) + .ToList(), + }; - var settings = GetAiSourceBubblesSettings(dialog); try { - var languages = await new AiSourceBubblesService( - settings - ).GetSupportedTargetLanguagesAsync(); + var options = await new AiTranslationService( + tempSettings + ).GetSupportedTargetLanguagesAsync(CancellationToken.None); + var languages = options.Select(option => new + { + tag = option.Value, + name = option.Label, + providerIds = option.ProviderIds, + }); request.ReplyWithJson(new { languages, message = String.Empty }); } catch (ArgumentException e) @@ -577,7 +564,7 @@ private async Task HandleGetAiSourceBubblesSupportedLanguagesAsync(ApiRequest re private void StoreAdvancedSettingsData(JObject data, CollectionSettingsDialog dialog) { - var aiSourceBubblesConfigurationChanged = false; + var aiTranslationConfigurationChanged = false; var autoUpdateToken = data["autoUpdate"]; if (autoUpdateToken != null) @@ -612,73 +599,80 @@ private void StoreAdvancedSettingsData(JObject data, CollectionSettingsDialog di dialog.PendingAllowAiSourceBubbles = allowAiSourceBubbles; } - var aiSourceBubblesProviderToken = data["aiSourceBubblesProvider"]; - if (aiSourceBubblesProviderToken != null) + var aiTranslationToken = data["aiTranslation"]; + if (aiTranslationToken != null) { - var providerId = AiSourceBubblesService.NormalizeProviderId( - aiSourceBubblesProviderToken.Value() - ); - aiSourceBubblesConfigurationChanged |= !String.Equals( - dialog.PendingAiSourceBubblesProviderId, - providerId, - StringComparison.OrdinalIgnoreCase - ); - dialog.PendingAiSourceBubblesProviderId = providerId; - } + var targetLanguageTagToken = aiTranslationToken["targetLanguageTag"]; + if (targetLanguageTagToken != null) + { + var targetLanguageTag = targetLanguageTagToken.Value(); + if ( + !String.Equals( + dialog.PendingAiTranslationTargetLanguageTag, + targetLanguageTag, + StringComparison.Ordinal + ) + ) + { + dialog.PendingAiTranslationTargetLanguageTag = targetLanguageTag; + foreach (var engineToInvalidate in dialog.PendingAiTranslationEngines) + InvalidateEngineValidation(engineToInvalidate); + aiTranslationConfigurationChanged = true; + } + } - var aiSourceBubblesTargetLanguageTagToken = data["aiSourceBubblesTargetLanguageTag"]; - if (aiSourceBubblesTargetLanguageTagToken != null) - { - var targetLanguageTag = aiSourceBubblesTargetLanguageTagToken.Value(); - aiSourceBubblesConfigurationChanged |= !String.Equals( - dialog.PendingAiSourceBubblesTargetLanguageTag, - targetLanguageTag, - StringComparison.Ordinal - ); - dialog.PendingAiSourceBubblesTargetLanguageTag = targetLanguageTag; - } + if (aiTranslationToken["engines"] is JArray enginesToken) + { + foreach (var engineToken in enginesToken) + { + var providerId = AiTranslationService.NormalizeProviderId( + engineToken["providerId"]?.Value() + ); + var engine = dialog.PendingAiTranslationEngines.SingleOrDefault(e => + e.ProviderId == providerId + ); + if (engine == null) + continue; + + var enabled = engineToken["enabled"]?.Value() ?? engine.Enabled; + var apiKey = engineToken["apiKey"]?.Value() ?? engine.ApiKey; + var serviceAccountEmail = + engineToken["serviceAccountEmail"]?.Value() + ?? engine.ServiceAccountEmail; + var privateKey = + engineToken["privateKey"]?.Value() ?? engine.PrivateKey; + + var engineChanged = + enabled != engine.Enabled + || !String.Equals(apiKey, engine.ApiKey, StringComparison.Ordinal) + || !String.Equals( + serviceAccountEmail, + engine.ServiceAccountEmail, + StringComparison.Ordinal + ) + || !String.Equals( + privateKey, + engine.PrivateKey, + StringComparison.Ordinal + ); - var aiSourceBubblesDeepLApiKeyToken = data["aiSourceBubblesDeepLApiKey"]; - if (aiSourceBubblesDeepLApiKeyToken != null) - { - var deepLApiKey = aiSourceBubblesDeepLApiKeyToken.Value(); - aiSourceBubblesConfigurationChanged |= !String.Equals( - dialog.PendingAiSourceBubblesDeepLApiKey, - deepLApiKey, - StringComparison.Ordinal - ); - dialog.PendingAiSourceBubblesDeepLApiKey = deepLApiKey; - } - var aiSourceBubblesGoogleServiceAccountEmailToken = data[ - "aiSourceBubblesGoogleServiceAccountEmail" - ]; - if (aiSourceBubblesGoogleServiceAccountEmailToken != null) - { - var googleServiceAccountEmail = - aiSourceBubblesGoogleServiceAccountEmailToken.Value(); - aiSourceBubblesConfigurationChanged |= !String.Equals( - dialog.PendingAiSourceBubblesGoogleServiceAccountEmail, - googleServiceAccountEmail, - StringComparison.Ordinal - ); - dialog.PendingAiSourceBubblesGoogleServiceAccountEmail = googleServiceAccountEmail; - } + engine.Enabled = enabled; + engine.ApiKey = apiKey; + engine.ServiceAccountEmail = serviceAccountEmail; + engine.PrivateKey = privateKey; - var aiSourceBubblesGooglePrivateKeyToken = data["aiSourceBubblesGooglePrivateKey"]; - if (aiSourceBubblesGooglePrivateKeyToken != null) - { - var googlePrivateKey = aiSourceBubblesGooglePrivateKeyToken.Value(); - aiSourceBubblesConfigurationChanged |= !String.Equals( - dialog.PendingAiSourceBubblesGooglePrivateKey, - googlePrivateKey, - StringComparison.Ordinal - ); - dialog.PendingAiSourceBubblesGooglePrivateKey = googlePrivateKey; + if (engineChanged) + { + InvalidateEngineValidation(engine); + aiTranslationConfigurationChanged = true; + } + } + } } - if (aiSourceBubblesConfigurationChanged) + if (aiTranslationConfigurationChanged) { - InvalidateAiSourceBubblesValidation(dialog); + dialog.ChangeThatRequiresRestart(); } var showQrCodeToken = data["showQrCode"]; diff --git a/src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs b/src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs deleted file mode 100644 index fe91394299d4..000000000000 --- a/src/BloomTests/AiSourceBubbles/AiSourceBubblesServiceTests.cs +++ /dev/null @@ -1,396 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Bloom; -using Bloom.AiSourceBubbles; -using Bloom.Collection; -using Bloom.SubscriptionAndFeatures; -using NUnit.Framework; - -namespace BloomTests.AiSourceBubbles -{ - [TestFixture] - public class AiSourceBubblesServiceTests - { - private bool _previousAiSourceBubblesEnabled; - - [SetUp] - public void Setup() - { - _previousAiSourceBubblesEnabled = ExperimentalFeatures.IsFeatureEnabled( - ExperimentalFeatures.kAiSourceBubbles - ); - ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, true); - } - - [TearDown] - public void TearDown() - { - ExperimentalFeatures.SetValue( - ExperimentalFeatures.kAiSourceBubbles, - _previousAiSourceBubblesEnabled - ); - } - - [Test] - public void NormalizeProviderId_GoogleTranslateAlias_ReturnsGoogle() - { - Assert.That( - AiSourceBubblesService.NormalizeProviderId("googleTranslate"), - Is.EqualTo("google") - ); - } - - [Test] - public void GetAiLanguageTag_AppendsNormalizedProviderSuffix() - { - Assert.That( - AiSourceBubblesService.GetAiLanguageTag("fr", "googleTranslate"), - Is.EqualTo("fr-x-ai-google") - ); - } - - [Test] - public void GetGoogleProjectIdFromServiceAccountEmail_ParsesProjectId() - { - Assert.That( - AiSourceBubblesService.GetGoogleProjectIdFromServiceAccountEmail( - "translator@test-project-123.iam.gserviceaccount.com" - ), - Is.EqualTo("test-project-123") - ); - } - - [Test] - public void GetConfigurationFingerprint_ChangesWhenRelevantSettingsChange() - { - var settings = MakeCollectionSettings("deepl"); - settings.AiSourceBubblesDeepLApiKey = "first-key"; - - var originalFingerprint = AiSourceBubblesService.GetConfigurationFingerprint(settings); - - settings.AiSourceBubblesDeepLApiKey = "second-key"; - - Assert.That( - AiSourceBubblesService.GetConfigurationFingerprint(settings), - Is.Not.EqualTo(originalFingerprint) - ); - } - - [Test] - public void GetConfigurationFingerprint_IgnoresUnusedProviderCredentials() - { - var settings = MakeCollectionSettings("deepl"); - settings.AiSourceBubblesDeepLApiKey = "deepl-key"; - settings.AiSourceBubblesGoogleServiceAccountEmail = "first@example.com"; - settings.AiSourceBubblesGooglePrivateKey = "first-private-key"; - - var originalFingerprint = AiSourceBubblesService.GetConfigurationFingerprint(settings); - - settings.AiSourceBubblesGoogleServiceAccountEmail = "second@example.com"; - settings.AiSourceBubblesGooglePrivateKey = "second-private-key"; - - Assert.That( - AiSourceBubblesService.GetConfigurationFingerprint(settings), - Is.EqualTo(originalFingerprint) - ); - } - - [Test] - public void NormalizeProviderId_Alpha2Alias_ReturnsEmpty() - { - Assert.That( - AiSourceBubblesService.NormalizeProviderId("alpha2"), - Is.EqualTo(string.Empty) - ); - } - - [Test] - public void TranslateAsync_WithoutTargetLanguageTag_ThrowsHelpfulError() - { - var collectionSettings = MakeCollectionSettings("deepl"); - collectionSettings.AiSourceBubblesTargetLanguageTag = ""; - var service = new AiSourceBubblesService(collectionSettings); - - var exception = Assert.ThrowsAsync(async () => - await service.TranslateAsync( - new AiSourceBubblesTranslateRequest - { - SourceText = "Hello world.", - SourceLanguageTag = "en", - } - ) - ); - - Assert.That(exception.Message, Does.Contain("target language tag")); - } - - [Test] - public async Task TranslateAsync_WritesRequestAndResponseToConsole() - { - var collectionSettings = MakeCollectionSettings("deepl"); - var fakeProvider = new FakeAiSourceBubblesTranslationProvider( - "deepl", - "Bonjour le monde." - ); - var service = new AiSourceBubblesService( - collectionSettings, - new Dictionary - { - { "deepl", fakeProvider }, - } - ); - var originalConsoleOut = Console.Out; - using (var output = new StringWriter()) - { - Console.SetOut(output); - - try - { - var result = await service.TranslateAsync( - new AiSourceBubblesTranslateRequest - { - SourceText = "Hello world.", - SourceLanguageTag = "en", - } - ); - - Assert.That(result.Text, Is.EqualTo("Bonjour le monde.")); - } - finally - { - Console.SetOut(originalConsoleOut); - } - - var log = output.ToString(); - Assert.That(log, Does.Contain("[AiSourceBubbles][request]")); - Assert.That(log, Does.Contain("[AiSourceBubbles][response]")); - Assert.That(log, Does.Contain("provider=deepl")); - Assert.That(log, Does.Contain("sourceLanguage=en")); - Assert.That(log, Does.Contain("targetLanguage=fr")); - Assert.That(log, Does.Contain("input=\"Hello world.\"")); - Assert.That(log, Does.Contain("output=\"Bonjour le monde.\"")); - Assert.That(log, Does.Contain("elapsedMs=")); - Assert.That(log, Does.Contain("time=")); - } - } - - [Test] - public void TranslateAsync_WhenProviderThrows_WritesFailureToConsole() - { - var collectionSettings = MakeCollectionSettings("deepl"); - var fakeProvider = new FakeAiSourceBubblesTranslationProvider( - "deepl", - exceptionToThrow: new InvalidOperationException("boom") - ); - var service = new AiSourceBubblesService( - collectionSettings, - new Dictionary - { - { "deepl", fakeProvider }, - } - ); - var originalConsoleOut = Console.Out; - using (var output = new StringWriter()) - { - Console.SetOut(output); - - try - { - var exception = Assert.ThrowsAsync(async () => - await service.TranslateAsync( - new AiSourceBubblesTranslateRequest - { - SourceText = "Hello world.", - SourceLanguageTag = "en", - } - ) - ); - - Assert.That(exception.Message, Is.EqualTo("boom")); - } - finally - { - Console.SetOut(originalConsoleOut); - } - - var log = output.ToString(); - Assert.That(log, Does.Contain("[AiSourceBubbles][request]")); - Assert.That(log, Does.Contain("[AiSourceBubbles][response]")); - Assert.That(log, Does.Contain("error=\"boom\"")); - Assert.That(log, Does.Contain("input=\"Hello world.\"")); - Assert.That(log, Does.Contain("elapsedMs=")); - Assert.That(log, Does.Contain("time=")); - } - } - - private static CollectionSettings MakeCollectionSettings(string providerId) - { - var collectionSettings = new CollectionSettings - { - Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), - AiSourceBubblesProviderId = providerId, - AiSourceBubblesTargetLanguageTag = "fr", - }; - return collectionSettings; - } - - private sealed class FakeAiSourceBubblesTranslationProvider - : IAiSourceBubblesTranslationProvider - { - private readonly string _translatedText; - private readonly Exception _exceptionToThrow; - - public FakeAiSourceBubblesTranslationProvider( - string providerId, - string translatedText = null, - Exception exceptionToThrow = null - ) - { - ProviderId = providerId; - _translatedText = translatedText; - _exceptionToThrow = exceptionToThrow; - } - - public string ProviderId { get; } - - public Task> GetSupportedTargetLanguagesAsync( - CollectionSettings collectionSettings, - HttpClient httpClient - ) - { - return Task.FromResult(new List()); - } - - public Task TranslateAsync( - CollectionSettings collectionSettings, - string sourceText, - string sourceLanguageTag, - string targetLanguageTag, - HttpClient httpClient - ) - { - if (_exceptionToThrow != null) - { - throw _exceptionToThrow; - } - - return Task.FromResult(_translatedText); - } - } - } - - public abstract class AiSourceBubblesLiveTranslationTestsBase - { - private bool _previousAiSourceBubblesEnabled; - - protected abstract string ProviderId { get; } - protected abstract string[] RequiredEnvironmentVariables { get; } - protected abstract void PopulateCredentials(CollectionSettings collectionSettings); - - [SetUp] - public void Setup() - { - _previousAiSourceBubblesEnabled = ExperimentalFeatures.IsFeatureEnabled( - ExperimentalFeatures.kAiSourceBubbles - ); - ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, true); - } - - [TearDown] - public void TearDown() - { - ExperimentalFeatures.SetValue( - ExperimentalFeatures.kAiSourceBubbles, - _previousAiSourceBubblesEnabled - ); - } - - [Test] - public async Task TranslateAsync_ConfiguredProvider_ReturnsTranslatedText() - { - var missingVariables = RequiredEnvironmentVariables - .Where(variableName => - string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(variableName)) - ) - .ToArray(); - if (missingVariables.Any()) - { - Assert.Ignore( - $"Manual AI Source Bubbles provider test. Set {string.Join(", ", missingVariables)} to run it." - ); - } - - var collectionSettings = new CollectionSettings - { - Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), - AiSourceBubblesProviderId = ProviderId, - AiSourceBubblesTargetLanguageTag = "fr", - }; - PopulateCredentials(collectionSettings); - var service = new AiSourceBubblesService(collectionSettings); - - var result = await service.TranslateAsync( - new AiSourceBubblesTranslateRequest - { - SourceText = "Hello world.", - SourceLanguageTag = "en", - } - ); - - Assert.That(result.ProviderId, Is.EqualTo(ProviderId)); - Assert.That(result.TargetLanguageTag, Is.EqualTo("fr")); - Assert.That(result.AiLanguageTag, Is.EqualTo($"fr-x-ai-{ProviderId}")); - Assert.That(result.Text, Is.Not.Null.And.Not.Empty); - Assert.That(result.Text, Is.Not.EqualTo("Hello world.")); - } - } - - [TestFixture] - [Category("SkipOnTeamCity")] - [NonParallelizable] - public class DeepLAiSourceBubblesLiveTranslationTests : AiSourceBubblesLiveTranslationTestsBase - { - protected override string ProviderId => "deepl"; - - protected override string[] RequiredEnvironmentVariables => new[] { "BLOOM_DEEPL_KEY" }; - - protected override void PopulateCredentials(CollectionSettings collectionSettings) - { - collectionSettings.AiSourceBubblesDeepLApiKey = Environment.GetEnvironmentVariable( - "BLOOM_DEEPL_KEY" - ); - } - } - - [TestFixture] - [Category("SkipOnTeamCity")] - [NonParallelizable] - public class GoogleAiSourceBubblesLiveTranslationTests : AiSourceBubblesLiveTranslationTestsBase - { - protected override string ProviderId => "google"; - - protected override string[] RequiredEnvironmentVariables => - new[] - { - "BLOOM_GOOGLE_TRANSLATION_SERVICE_ACCOUNT_EMAIL", - "BLOOM_GOOGLE_TRANSLATION_SERVICE_PRIVATE_KEY", - }; - - protected override void PopulateCredentials(CollectionSettings collectionSettings) - { - collectionSettings.AiSourceBubblesGoogleServiceAccountEmail = - Environment.GetEnvironmentVariable( - "BLOOM_GOOGLE_TRANSLATION_SERVICE_ACCOUNT_EMAIL" - ); - collectionSettings.AiSourceBubblesGooglePrivateKey = Environment.GetEnvironmentVariable( - "BLOOM_GOOGLE_TRANSLATION_SERVICE_PRIVATE_KEY" - ); - } - } -} diff --git a/src/BloomTests/AiTranslation/AiTranslationBookScannerTests.cs b/src/BloomTests/AiTranslation/AiTranslationBookScannerTests.cs new file mode 100644 index 000000000000..0bd3b20d84e7 --- /dev/null +++ b/src/BloomTests/AiTranslation/AiTranslationBookScannerTests.cs @@ -0,0 +1,560 @@ +using System.Collections.Generic; +using System.Linq; +using Bloom.AiTranslation; +using Bloom.Book; +using Bloom.Collection; +using Bloom.SafeXml; +using NUnit.Framework; + +namespace BloomTests.AiTranslation +{ + [TestFixture] + public class AiTranslationBookScannerTests + { + private static HtmlDom MakeBookDom(string pagesHtml, string dataDivHtml = "") + { + return new HtmlDom( + $@" +
    {dataDivHtml}
    + {pagesHtml} + " + ); + } + + [Test] + public void Scan_FindsGroupsInDocumentOrder_WithSourceTextChosenFromPriorities() + { + var dom = MakeBookDom( + @" +
    +
    +
    English 1
    +
    French 1
    +
    +
    +
    +
    +
    English 2
    +
    French 2
    +
    +
    " + ); + Assert.That( + dom.RawDom.SafeSelectNodes( + "//div[contains(@class,'bloom-translationGroup')]" + ).Length, + Is.EqualTo(2), + "sanity check: fixture should have 2 translation groups" + ); + + var scanner = new AiTranslationBookScanner( + dom, + "es", + new List(), + new[] { "fr", "en" } + ); + + var scan = scanner.Scan(); + + Assert.That(scan.Groups.Count, Is.EqualTo(2)); + Assert.That(scan.Groups[0].SourceText, Is.EqualTo("French 1")); + Assert.That(scan.Groups[0].SourceLanguageTag, Is.EqualTo("fr")); + Assert.That(scan.Groups[1].SourceText, Is.EqualTo("French 2")); + } + + [Test] + public void Scan_PriorityFallback_UsesSecondPriorityWhenFirstEmpty_ThenAnyNonEmptyIfNoPriorityMatches() + { + var dom = MakeBookDom( + @" +
    +
    +
    +
    English text
    +
    +
    +
    Spanish text
    +
    +
    " + ); + + var scanner = new AiTranslationBookScanner( + dom, + "de", + new List(), + new[] { "fr", "en" } + ); + + var scan = scanner.Scan(); + + Assert.That(scan.Groups.Count, Is.EqualTo(2)); + Assert.That( + scan.Groups[0].SourceText, + Is.EqualTo("English text"), + "first priority (fr) is empty, so it should fall back to the second priority (en)" + ); + Assert.That( + scan.Groups[1].SourceText, + Is.EqualTo("Spanish text"), + "no priority matches (only 'es' is present), so it should fall back to the first non-empty editable" + ); + } + + [Test] + public void ChooseSourceDiv_SkipsZLanguageAndAiLanguageEditables() + { + var dom = MakeBookDom( + @" +
    +
    +
    Z placeholder
    +
    Existing AI text
    +
    English text
    +
    +
    " + ); + + var scanner = new AiTranslationBookScanner( + dom, + "es", + new List(), + new string[0] // no priorities match, so this exercises the fallback path + ); + + var scan = scanner.Scan(); + + Assert.That(scan.Groups.Count, Is.EqualTo(1)); + Assert.That(scan.Groups[0].SourceText, Is.EqualTo("English text")); + } + + [Test] + public void Scan_SkipsGroupsWithEmptyOrWhitespaceOnlySourceText() + { + var dom = MakeBookDom( + @" +
    +
    +
    +
    +
    +
    Real text
    +
    +
    " + ); + + var scanner = new AiTranslationBookScanner( + dom, + "es", + new List(), + new[] { "en" } + ); + + var scan = scanner.Scan(); + + Assert.That(scan.Groups.Count, Is.EqualTo(1)); + Assert.That(scan.Groups[0].SourceText, Is.EqualTo("Real text")); + } + + [Test] + public void Scan_SkipsNoSourceBubbleReadOnlyAndOtherBookDataGroups() + { + var dom = MakeBookDom( + @" +
    +
    +
    Should be skipped 1
    +
    +
    +
    Should be skipped 2
    +
    +
    +
    Should be skipped 3
    +
    +
    +
    Should be included
    +
    +
    " + ); + + var scanner = new AiTranslationBookScanner( + dom, + "es", + new List(), + new[] { "en" } + ); + + var scan = scanner.Scan(); + + Assert.That(scan.Groups.Count, Is.EqualTo(1)); + Assert.That(scan.Groups[0].SourceText, Is.EqualTo("Should be included")); + } + + [Test] + public void Scan_IncludesBookTitleGroup_WithIsBookTitleTrue() + { + var dom = MakeBookDom( + @" +
    +
    +
    My Book
    +
    +
    " + ); + + var scanner = new AiTranslationBookScanner( + dom, + "es", + new List(), + new[] { "en" } + ); + + var scan = scanner.Scan(); + + Assert.That(scan.Groups.Count, Is.EqualTo(1)); + Assert.That(scan.Groups[0].IsBookTitle, Is.True); + Assert.That(scan.Groups[0].SourceText, Is.EqualTo("My Book")); + } + + [Test] + public void GroupsNeedingTranslation_CurrentFingerprintNotIncluded_WrongFingerprintOrEmptyIncluded() + { + var engine = new AiTranslationEngineSettings { ProviderId = "deepl", Enabled = true }; + var aiTag = AiTranslationService.GetAiLanguageTag("es", "deepl"); + Assert.That(aiTag, Is.EqualTo("es-x-ai-deepl"), "sanity check: expected AI tag format"); + var currentFingerprint = AiTranslationBookScanner.ComputeFingerprint( + "en", + "Hello", + aiTag + ); + + var dom = MakeBookDom( + $@" +
    +
    +
    Hello
    +
    Bonjour
    +
    +
    +
    World
    +
    OldTranslation
    +
    +
    +
    Empty div case
    +
    +
    +
    " + ); + + var scanner = new AiTranslationBookScanner( + dom, + "es", + new List { engine }, + new[] { "en" } + ); + + var scan = scanner.Scan(); + Assert.That( + scan.Groups.Count, + Is.EqualTo(3), + "sanity check: all three groups should be eligible" + ); + + var needing = scan.GroupsNeedingTranslation(engine); + + Assert.That(needing.Count, Is.EqualTo(2)); + Assert.That( + needing.Select(g => g.SourceText), + Is.EquivalentTo(new[] { "World", "Empty div case" }) + ); + } + + [Test] + public void ApplyTranslation_WritesDivWithClassLangFingerprint_AndReplacesOnSecondCall() + { + var engine = new AiTranslationEngineSettings { ProviderId = "deepl", Enabled = true }; + var dom = MakeBookDom( + @" +
    +
    +
    Hello
    +
    +
    " + ); + + var scanner = new AiTranslationBookScanner( + dom, + "es", + new List { engine }, + new[] { "en" } + ); + var group = scanner.Scan().Groups.Single(); + Assert.That( + group.SourceText, + Is.EqualTo("Hello"), + "sanity check on chosen source text" + ); + + scanner.ApplyTranslation(group, engine, "Hola"); + + var aiTag = AiTranslationService.GetAiLanguageTag("es", "deepl"); + var aiDivs = group.GroupElement.SafeSelectElements($"div[@lang='{aiTag}']"); + Assert.That( + aiDivs.Length, + Is.EqualTo(1), + "sanity check: exactly one AI div should exist after the first apply" + ); + + var aiDiv = aiDivs[0]; + Assert.That(aiDiv.InnerText, Is.EqualTo("Hola")); + Assert.That(aiDiv.HasClass("bloom-editable"), Is.True); + Assert.That(aiDiv.HasClass("bloom-ai-translation"), Is.True); + var expectedFingerprint = AiTranslationBookScanner.ComputeFingerprint( + "en", + "Hello", + aiTag + ); + Assert.That(aiDiv.GetAttribute("data-ai-fingerprint"), Is.EqualTo(expectedFingerprint)); + + // Applying again should replace the existing div, not duplicate it. + scanner.ApplyTranslation(group, engine, "Hola de nuevo"); + var aiDivsAfterSecondApply = group.GroupElement.SafeSelectElements( + $"div[@lang='{aiTag}']" + ); + Assert.That(aiDivsAfterSecondApply.Length, Is.EqualTo(1)); + Assert.That(aiDivsAfterSecondApply[0].InnerText, Is.EqualTo("Hola de nuevo")); + } + + [Test] + public void ApplyTranslation_ForBookTitleGroup_WritesDataDivEntry() + { + var engine = new AiTranslationEngineSettings { ProviderId = "google", Enabled = true }; + var dom = MakeBookDom( + @" +
    +
    +
    My Book
    +
    +
    " + ); + + var scanner = new AiTranslationBookScanner( + dom, + "fr", + new List { engine }, + new[] { "en" } + ); + var group = scanner.Scan().Groups.Single(); + Assert.That( + group.IsBookTitle, + Is.True, + "sanity check: this group should be recognized as the book title group" + ); + + scanner.ApplyTranslation(group, engine, "Mon Livre"); + + var aiTag = AiTranslationService.GetAiLanguageTag("fr", "google"); + var dataDiv = + dom.RawDom.SelectSingleNode("//div[@id='bloomDataDiv']") as SafeXmlElement; + var titleEntries = dataDiv.SafeSelectElements( + $"div[@data-book='bookTitle' and @lang='{aiTag}']" + ); + Assert.That(titleEntries.Length, Is.EqualTo(1)); + Assert.That(titleEntries[0].InnerText, Is.EqualTo("Mon Livre")); + } + + [Test] + public void ApplyTranslation_BookTitleGroup_NoDataDivPresent_DoesNotThrow() + { + var engine = new AiTranslationEngineSettings { ProviderId = "deepl", Enabled = true }; + var dom = new HtmlDom( + @" +
    +
    +
    My Book
    +
    +
    + " + ); + + var scanner = new AiTranslationBookScanner( + dom, + "es", + new List { engine }, + new[] { "en" } + ); + var group = scanner.Scan().Groups.Single(); + + Assert.DoesNotThrow(() => scanner.ApplyTranslation(group, engine, "Mi Libro")); + } + + [Test] + public void RemoveStaleAiDivs_RemovesDisabledEngineAndStaleFingerprintAndOrphanedDivs_KeepsCurrentOnes_CleansDataDiv() + { + var enabledEngine = new AiTranslationEngineSettings + { + ProviderId = "deepl", + Enabled = true, + }; + const string targetTag = "es"; + var enabledAiTag = AiTranslationService.GetAiLanguageTag(targetTag, "deepl"); + var disabledAiTag = AiTranslationService.GetAiLanguageTag(targetTag, "google"); // google isn't in the enabled-engines list below + var currentFingerprint = AiTranslationBookScanner.ComputeFingerprint( + "en", + "Hello", + enabledAiTag + ); + + var dom = MakeBookDom( + $@" +
    +
    +
    Hello
    +
    Hola
    +
    Should be removed (disabled engine)
    +
    +
    +
    World
    +
    Should be removed (stale fingerprint)
    +
    +
    +
    Excluded group
    +
    Should be removed (orphaned)
    +
    +
    ", + dataDivHtml: $@"
    Stale title translation
    " + ); + + Assert.That( + dom.RawDom.SafeSelectNodes("//div[@lang and contains(@lang,'-x-ai')]").Length, + Is.EqualTo(5), + "sanity check: fixture should start with 5 AI divs total (4 in pages, 1 in the data div)" + ); + + var scanner = new AiTranslationBookScanner( + dom, + targetTag, + new List { enabledEngine }, + new[] { "en" } + ); + + var removedCount = scanner.RemoveStaleAiDivs(); + + Assert.That( + removedCount, + Is.EqualTo(4), + "3 in the pages (disabled-engine, stale-fingerprint, orphaned) plus the data-div bookTitle entry (no matching valid page-level div)" + ); + + var remainingAiDivs = dom.RawDom.SafeSelectElements( + "//div[@lang and contains(@lang,'-x-ai')]" + ); + Assert.That(remainingAiDivs.Length, Is.EqualTo(1)); + Assert.That(remainingAiDivs[0].GetAttribute("lang"), Is.EqualTo(enabledAiTag)); + Assert.That(remainingAiDivs[0].InnerText, Is.EqualTo("Hola")); + } + + [Test] + public void RemoveStaleAiDivs_KeepsCurrentBookTitleDataDivEntry_WhenPageLevelDivIsStillCurrent() + { + var engine = new AiTranslationEngineSettings { ProviderId = "deepl", Enabled = true }; + const string targetTag = "es"; + var aiTag = AiTranslationService.GetAiLanguageTag(targetTag, "deepl"); + var currentFingerprint = AiTranslationBookScanner.ComputeFingerprint( + "en", + "My Book", + aiTag + ); + + var dom = MakeBookDom( + $@" +
    +
    +
    My Book
    +
    Mi Libro
    +
    +
    ", + dataDivHtml: $@"
    Mi Libro
    " + ); + + var scanner = new AiTranslationBookScanner( + dom, + targetTag, + new List { engine }, + new[] { "en" } + ); + + var removedCount = scanner.RemoveStaleAiDivs(); + + Assert.That(removedCount, Is.EqualTo(0)); + var dataDiv = + dom.RawDom.SelectSingleNode("//div[@id='bloomDataDiv']") as SafeXmlElement; + Assert.That( + dataDiv + .SafeSelectElements($"div[@data-book='bookTitle' and @lang='{aiTag}']") + .Length, + Is.EqualTo(1), + "the current data-div bookTitle entry should be kept since the page-level div is still current" + ); + } + + [Test] + public void RemoveAllAiDivs_RemovesEveryAiDivIncludingCurrentOnes_KeepsNonAiDivs_CleansDataDiv() + { + const string targetTag = "es"; + var deepLTag = AiTranslationService.GetAiLanguageTag(targetTag, "deepl"); + var googleTag = AiTranslationService.GetAiLanguageTag(targetTag, "google"); + // A perfectly current translation: RemoveStaleAiDivs would keep this, but RemoveAllAiDivs + // must remove it too. + var currentFingerprint = AiTranslationBookScanner.ComputeFingerprint( + "en", + "Hello", + deepLTag + ); + + var dom = MakeBookDom( + $@" +
    +
    +
    Hello
    +
    Hola
    +
    Hola (google)
    +
    +
    +
    Excluded group
    +
    Orphaned AI text
    +
    +
    ", + dataDivHtml: $@"
    Title translation
    " + ); + + Assert.That( + dom.RawDom.SafeSelectNodes("//div[@lang and contains(@lang,'-x-ai')]").Length, + Is.EqualTo(4), + "sanity check: fixture should start with 4 AI divs total (3 in pages, 1 in the data div)" + ); + + // Constructed with no engine/target/priority context at all, to prove RemoveAllAiDivs + // does not depend on any of them (this is how the menu command calls it). + var scanner = new AiTranslationBookScanner(dom, null, null, null); + + var removedCount = scanner.RemoveAllAiDivs(); + + Assert.That( + removedCount, + Is.EqualTo(4), + "every AI div should be removed: current, disabled-engine, orphaned, and the data-div entry" + ); + Assert.That( + dom.RawDom.SafeSelectNodes("//div[@lang and contains(@lang,'-x-ai')]").Length, + Is.EqualTo(0), + "no AI divs should remain anywhere in the book" + ); + // The non-AI source divs must be untouched. + Assert.That( + dom.RawDom.SafeSelectNodes("//div[@lang='en']").Length, + Is.EqualTo(2), + "both English source divs should be left in place" + ); + } + } +} diff --git a/src/BloomTests/AiTranslation/AiTranslationBookUpdaterTests.cs b/src/BloomTests/AiTranslation/AiTranslationBookUpdaterTests.cs new file mode 100644 index 000000000000..d099dc5f3558 --- /dev/null +++ b/src/BloomTests/AiTranslation/AiTranslationBookUpdaterTests.cs @@ -0,0 +1,427 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Bloom.AiTranslation; +using Bloom.Book; +using Bloom.Collection; +using BloomTests.TeamCollection; +using NUnit.Framework; + +namespace BloomTests.AiTranslation +{ + /// + /// Tests for the dialog-free orchestration core of AiTranslationBookUpdater: RunEnginesAsync (the + /// parallel translate phase) and ApplyOutcomes (the DOM-write phase). The dialog-hosting parts + /// (RunWithProgressDialog, MakeDialog) require a real Form/ShowDialog and are not unit tested here; + /// see the manual test plan for those. + /// + [TestFixture] + public class AiTranslationBookUpdaterTests + { + private static HtmlDom MakeBookDom(string pagesHtml, string dataDivHtml = "") + { + return new HtmlDom( + $@" +
    {dataDivHtml}
    + {pagesHtml} + " + ); + } + + private static AiTranslationEngineSettings MakeEngine(string providerId) => + new AiTranslationEngineSettings { ProviderId = providerId, Enabled = true }; + + [Test] + public async Task RunEnginesAsync_NothingNeeded_MakesNoTranslateCalls() + { + var engine = MakeEngine("deepl"); + var aiTag = AiTranslationService.GetAiLanguageTag("es", "deepl"); + var currentFingerprint = AiTranslationBookScanner.ComputeFingerprint( + "en", + "Hello", + aiTag + ); + var dom = MakeBookDom( + $@" +
    +
    +
    Hello
    +
    Hola
    +
    +
    " + ); + var scanner = new AiTranslationBookScanner(dom, "es", new[] { engine }, new[] { "en" }); + var scan = scanner.Scan(); + Assert.That( + scan.GroupsNeedingTranslation(engine), + Is.Empty, + "sanity check: the existing translation should already be current" + ); + + var callCount = 0; + Task translate( + AiTranslationEngineSettings e, + string[] segments, + string sourceLang, + CancellationToken ct + ) + { + callCount++; + return Task.FromResult(segments); + } + + var outcomes = await AiTranslationBookUpdater.RunEnginesAsync( + scan, + new[] { engine }, + translate, + new ProgressSpy(), + CancellationToken.None + ); + + Assert.That( + callCount, + Is.EqualTo(0), + "no engine has work, so translate must never be called" + ); + Assert.That(outcomes, Is.Empty); + } + + [Test] + public async Task RunEnginesAsync_TwoEngines_GroupsByLanguageAndAppliesCorrectTextsToCorrectGroups() + { + var deepl = MakeEngine("deepl"); + var google = MakeEngine("google"); + var dom = MakeBookDom( + @" +
    +
    +
    Hello
    +
    +
    +
    Bonjour
    +
    +
    " + ); + var scanner = new AiTranslationBookScanner( + dom, + "es", + new[] { deepl, google }, + new[] { "en", "fr" } + ); + var scan = scanner.Scan(); + Assert.That(scan.Groups.Count, Is.EqualTo(2), "sanity check on fixture"); + + var calls = new List<(string Engine, string Lang, string[] Segments)>(); + Task translate( + AiTranslationEngineSettings e, + string[] segments, + string sourceLang, + CancellationToken ct + ) + { + lock (calls) + calls.Add((e.ProviderId, sourceLang, segments)); + return Task.FromResult( + segments.Select(s => $"{e.ProviderId}/{sourceLang}:{s}").ToArray() + ); + } + + var outcomes = await AiTranslationBookUpdater.RunEnginesAsync( + scan, + new[] { deepl, google }, + translate, + new ProgressSpy(), + CancellationToken.None + ); + + Assert.That(outcomes.Count, Is.EqualTo(2), "both engines had work"); + Assert.That(calls.Count, Is.EqualTo(4), "2 engines x 2 source languages (en, fr) each"); + foreach (var outcome in outcomes) + { + Assert.That(outcome.Succeeded, Is.True); + Assert.That(outcome.Translations.Count, Is.EqualTo(2)); + var byText = outcome.Translations.ToDictionary( + t => t.Group.SourceText, + t => t.TranslatedText + ); + Assert.That( + byText["Hello"], + Is.EqualTo($"{outcome.Engine.ProviderId}/en:Hello"), + "the English group's translation should come from the en-language call" + ); + Assert.That( + byText["Bonjour"], + Is.EqualTo($"{outcome.Engine.ProviderId}/fr:Bonjour"), + "the French group's translation should come from the fr-language call" + ); + } + } + + [Test] + public async Task RunEnginesAsync_TwoEngines_RunConcurrentlyNotSequentially() + { + var deepl = MakeEngine("deepl"); + var google = MakeEngine("google"); + var dom = MakeBookDom( + @"
    +
    Hello
    +
    " + ); + var scanner = new AiTranslationBookScanner( + dom, + "es", + new[] { deepl, google }, + new[] { "en" } + ); + var scan = scanner.Scan(); + + var deeplStarted = new TaskCompletionSource(); + var googleStarted = new TaskCompletionSource(); + + async Task translate( + AiTranslationEngineSettings e, + string[] segments, + string sourceLang, + CancellationToken ct + ) + { + if (e.ProviderId == "deepl") + { + deeplStarted.SetResult(true); + await Task.WhenAny(googleStarted.Task, Task.Delay(5000, ct)); + } + else + { + googleStarted.SetResult(true); + await Task.WhenAny(deeplStarted.Task, Task.Delay(5000, ct)); + } + return segments; + } + + await AiTranslationBookUpdater.RunEnginesAsync( + scan, + new[] { deepl, google }, + translate, + new ProgressSpy(), + CancellationToken.None + ); + + Assert.That( + deeplStarted.Task.IsCompletedSuccessfully + && googleStarted.Task.IsCompletedSuccessfully, + Is.True, + "each engine's translate call should have observed the other one having also started, proving they ran concurrently rather than one waiting for the other to fully finish" + ); + } + + [Test] + public async Task RunEnginesAsync_OneEngineThrows_OtherEnginesResultsStillApplied_FailureRecorded() + { + var deepl = MakeEngine("deepl"); + var google = MakeEngine("google"); + var dom = MakeBookDom( + @"
    +
    Hello
    +
    " + ); + var scanner = new AiTranslationBookScanner( + dom, + "es", + new[] { deepl, google }, + new[] { "en" } + ); + var scan = scanner.Scan(); + + Task translate( + AiTranslationEngineSettings e, + string[] segments, + string sourceLang, + CancellationToken ct + ) + { + if (e.ProviderId == "deepl") + throw new InvalidOperationException("DeepL quota exceeded"); + return Task.FromResult(segments.Select(s => $"google:{s}").ToArray()); + } + + var outcomes = await AiTranslationBookUpdater.RunEnginesAsync( + scan, + new[] { deepl, google }, + translate, + new ProgressSpy(), + CancellationToken.None + ); + + var deeplOutcome = outcomes.Single(o => o.Engine.ProviderId == "deepl"); + var googleOutcome = outcomes.Single(o => o.Engine.ProviderId == "google"); + + Assert.That(deeplOutcome.Succeeded, Is.False); + Assert.That(deeplOutcome.ErrorMessage, Does.Contain("DeepL quota exceeded")); + Assert.That(deeplOutcome.Translations, Is.Empty); + + Assert.That(googleOutcome.Succeeded, Is.True); + Assert.That( + googleOutcome.Translations.Single().TranslatedText, + Is.EqualTo("google:Hello") + ); + } + + [Test] + public void RunEnginesAsync_Cancelled_ThrowsAndLeavesDomUnchanged() + { + var engine = MakeEngine("deepl"); + var dom = MakeBookDom( + @"
    +
    Hello
    +
    " + ); + var scanner = new AiTranslationBookScanner(dom, "es", new[] { engine }, new[] { "en" }); + var scan = scanner.Scan(); + var group = scan.Groups.Single(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); // simulate the user having already clicked Cancel + + Task translate( + AiTranslationEngineSettings e, + string[] segments, + string sourceLang, + CancellationToken ct + ) + { + Assert.Fail( + "translate should never be called once cancellation has already been requested" + ); + return Task.FromResult(segments); + } + + Assert.ThrowsAsync(async () => + await AiTranslationBookUpdater.RunEnginesAsync( + scan, + new[] { engine }, + translate, + new ProgressSpy(), + cts.Token + ) + ); + + // Since RunEnginesAsync threw, the real caller (AiTranslationBookUpdater.RunWithProgressDialog) + // never reaches ApplyOutcomes/RemoveStaleAiDivs/book.Save() -- so nothing should have touched + // the DOM. Confirm no AI div was ever written for the one group that would otherwise have + // been translated. + var aiTag = AiTranslationService.GetAiLanguageTag("es", "deepl"); + Assert.That( + group.GroupElement.SafeSelectElements($"div[@lang='{aiTag}']"), + Is.Empty, + "cancellation must leave the book DOM completely untouched" + ); + } + + [Test] + public async Task ApplyOutcomes_WritesEachEnginesTranslationsToTheDom_AndRemoveStaleAiDivsThenCleansUp() + { + var deepl = MakeEngine("deepl"); + var staleEngineTag = AiTranslationService.GetAiLanguageTag("es", "deepl"); + // A group that already has a stale (wrong-fingerprint) AI div, to prove the success path's + // subsequent RemoveStaleAiDivs() call still cleans it up after ApplyOutcomes runs. + var dom = MakeBookDom( + $@" +
    +
    +
    Hello
    +
    +
    +
    World
    +
    Old
    +
    +
    " + ); + var scanner = new AiTranslationBookScanner(dom, "es", new[] { deepl }, new[] { "en" }); + var scan = scanner.Scan(); + + Task translate( + AiTranslationEngineSettings e, + string[] segments, + string sourceLang, + CancellationToken ct + ) => Task.FromResult(segments.Select(s => $"ES:{s}").ToArray()); + + var outcomes = await AiTranslationBookUpdater.RunEnginesAsync( + scan, + new[] { deepl }, + translate, + new ProgressSpy(), + CancellationToken.None + ); + + AiTranslationBookUpdater.ApplyOutcomes(scanner, outcomes); + + var aiTag = AiTranslationService.GetAiLanguageTag("es", "deepl"); + var helloGroupAiDiv = scan + .Groups.First(g => g.SourceText == "Hello") + .GroupElement.SafeSelectElements($"div[@lang='{aiTag}']") + .Single(); + Assert.That(helloGroupAiDiv.InnerText, Is.EqualTo("ES:Hello")); + + var worldGroupAiDiv = scan + .Groups.First(g => g.SourceText == "World") + .GroupElement.SafeSelectElements($"div[@lang='{aiTag}']") + .Single(); + Assert.That( + worldGroupAiDiv.InnerText, + Is.EqualTo("ES:World"), + "ApplyTranslation should have replaced the previously-stale div with the fresh translation" + ); + + // Only on the success path does the real caller go on to call RemoveStaleAiDivs(); simulate + // that here directly against the same scanner/DOM now that ApplyOutcomes has run. + var removedCount = scanner.RemoveStaleAiDivs(); + Assert.That( + removedCount, + Is.EqualTo(0), + "both AI divs are now current (freshly applied), so there should be nothing left to remove" + ); + } + + [Test] + public void CountStaleAiDivsWithoutMutating_ReportsCountWithoutTouchingTheRealDom() + { + var deepl = MakeEngine("deepl"); + var staleTag = AiTranslationService.GetAiLanguageTag("es", "deepl"); + var dom = MakeBookDom( + $@" +
    +
    +
    Hello
    +
    Old
    +
    +
    " + ); + + var countBefore = dom + .RawDom.SafeSelectNodes("//div[@lang and contains(@lang,'-x-ai')]") + .Length; + Assert.That(countBefore, Is.EqualTo(1), "sanity check on fixture"); + + var staleCount = AiTranslationBookUpdater.CountStaleAiDivsWithoutMutating( + dom, + "es", + new[] { deepl }, + new[] { "en" } + ); + + Assert.That(staleCount, Is.EqualTo(1)); + // The real DOM must be untouched -- the stale div should still be there. + var countAfter = dom + .RawDom.SafeSelectNodes("//div[@lang and contains(@lang,'-x-ai')]") + .Length; + Assert.That( + countAfter, + Is.EqualTo(1), + "CountStaleAiDivsWithoutMutating must not mutate the real book DOM" + ); + } + } +} diff --git a/src/BloomTests/AiTranslation/AiTranslationServiceTests.cs b/src/BloomTests/AiTranslation/AiTranslationServiceTests.cs new file mode 100644 index 000000000000..8d349a4f67e5 --- /dev/null +++ b/src/BloomTests/AiTranslation/AiTranslationServiceTests.cs @@ -0,0 +1,602 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Bloom; +using Bloom.AiTranslation; +using Bloom.Collection; +using Bloom.SubscriptionAndFeatures; +using NUnit.Framework; + +namespace BloomTests.AiTranslation +{ + [TestFixture] + public class AiTranslationServiceTests + { + private bool _previousAiSourceBubblesEnabled; + + [SetUp] + public void Setup() + { + _previousAiSourceBubblesEnabled = ExperimentalFeatures.IsFeatureEnabled( + ExperimentalFeatures.kAiSourceBubbles + ); + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, true); + } + + [TearDown] + public void TearDown() + { + ExperimentalFeatures.SetValue( + ExperimentalFeatures.kAiSourceBubbles, + _previousAiSourceBubblesEnabled + ); + } + + [Test] + public void NormalizeProviderId_GoogleTranslateAlias_ReturnsGoogle() + { + Assert.That( + AiTranslationService.NormalizeProviderId("googleTranslate"), + Is.EqualTo("google") + ); + } + + [Test] + public void GetAiLanguageTag_AppendsNormalizedProviderSuffix() + { + Assert.That( + AiTranslationService.GetAiLanguageTag("fr", "googleTranslate"), + Is.EqualTo("fr-x-ai-google") + ); + } + + [Test] + public void GetGoogleProjectIdFromServiceAccountEmail_ParsesProjectId() + { + Assert.That( + AiTranslationService.GetGoogleProjectIdFromServiceAccountEmail( + "translator@test-project-123.iam.gserviceaccount.com" + ), + Is.EqualTo("test-project-123") + ); + } + + [Test] + public void GetEngineFingerprint_ChangesWhenApiKeyChanges() + { + var engine = new AiTranslationEngineSettings + { + ProviderId = "deepl", + ApiKey = "first-key", + }; + var originalFingerprint = AiTranslationService.GetEngineFingerprint(engine, "fr"); + + engine.ApiKey = "second-key"; + + Assert.That( + AiTranslationService.GetEngineFingerprint(engine, "fr"), + Is.Not.EqualTo(originalFingerprint) + ); + } + + [Test] + public void GetEngineFingerprint_StableForUnchangedSettings() + { + var engine = new AiTranslationEngineSettings + { + ProviderId = "deepl", + ApiKey = "same-key", + }; + + var first = AiTranslationService.GetEngineFingerprint(engine, "fr"); + var second = AiTranslationService.GetEngineFingerprint(engine, "fr"); + + Assert.That(second, Is.EqualTo(first)); + } + + [Test] + public void GetEngineFingerprint_ChangesWhenTargetLanguageChanges() + { + var engine = new AiTranslationEngineSettings + { + ProviderId = "deepl", + ApiKey = "same-key", + }; + + var frenchFingerprint = AiTranslationService.GetEngineFingerprint(engine, "fr"); + var spanishFingerprint = AiTranslationService.GetEngineFingerprint(engine, "es"); + + Assert.That(spanishFingerprint, Is.Not.EqualTo(frenchFingerprint)); + } + + [Test] + public void GetEngineFingerprint_IsIndependentOfOtherEnginesCredentials() + { + // Two independently-configured engines should each fingerprint based only on their + // own provider/target/credentials, regardless of what other engines are configured with. + var deeplEngine = new AiTranslationEngineSettings + { + ProviderId = "deepl", + ApiKey = "deepl-key", + }; + var googleEngine = new AiTranslationEngineSettings + { + ProviderId = "google", + ServiceAccountEmail = "first@example.com", + PrivateKey = "first-private-key", + }; + + var originalFingerprint = AiTranslationService.GetEngineFingerprint(deeplEngine, "fr"); + + googleEngine.ServiceAccountEmail = "second@example.com"; + googleEngine.PrivateKey = "second-private-key"; + + Assert.That( + AiTranslationService.GetEngineFingerprint(deeplEngine, "fr"), + Is.EqualTo(originalFingerprint) + ); + } + + [Test] + public void TranslateSegmentsAsync_WithoutTargetLanguageTag_ThrowsHelpfulError() + { + var collectionSettings = MakeCollectionSettings(); + collectionSettings.AiTranslationTargetLanguageTag = ""; + var service = new AiTranslationService(collectionSettings); + var engine = new AiTranslationEngineSettings { ProviderId = "deepl", ApiKey = "key" }; + + var exception = Assert.ThrowsAsync(async () => + await service.TranslateSegmentsAsync( + engine, + new[] { "Hello world." }, + "en", + CancellationToken.None + ) + ); + + Assert.That(exception.Message, Does.Contain("target language tag")); + } + + [Test] + public async Task TranslateSegmentsAsync_FewerSegmentsThanLimit_MakesOneCall() + { + var fakeProvider = new FakeAiTranslationProvider( + "deepl", + maxSegmentsPerRequest: 50, + maxRequestBytes: 1_000_000 + ); + var service = MakeService(fakeProvider); + var engine = new AiTranslationEngineSettings { ProviderId = "deepl", ApiKey = "key" }; + var segments = Enumerable.Range(0, 50).Select(i => $"segment {i}").ToArray(); + + var results = await service.TranslateSegmentsAsync( + engine, + segments, + "en", + CancellationToken.None + ); + + Assert.That(fakeProvider.Calls.Count, Is.EqualTo(1)); + Assert.That(results, Is.EqualTo(segments.Select(s => "[fr] " + s))); + } + + [Test] + public async Task TranslateSegmentsAsync_MoreSegmentsThanLimit_ChunksBySegmentCount() + { + var fakeProvider = new FakeAiTranslationProvider( + "deepl", + maxSegmentsPerRequest: 50, + maxRequestBytes: 1_000_000 + ); + var service = MakeService(fakeProvider); + var engine = new AiTranslationEngineSettings { ProviderId = "deepl", ApiKey = "key" }; + var segments = Enumerable.Range(0, 51).Select(i => $"segment {i}").ToArray(); + + var results = await service.TranslateSegmentsAsync( + engine, + segments, + "en", + CancellationToken.None + ); + + Assert.That(fakeProvider.Calls.Count, Is.EqualTo(2)); + Assert.That(fakeProvider.Calls[0].Length, Is.EqualTo(50)); + Assert.That(fakeProvider.Calls[1].Length, Is.EqualTo(1)); + Assert.That(results, Is.EqualTo(segments.Select(s => "[fr] " + s))); + } + + [Test] + public async Task TranslateSegmentsAsync_ChunksByByteSize() + { + var fakeProvider = new FakeAiTranslationProvider( + "deepl", + maxSegmentsPerRequest: 1000, + maxRequestBytes: 30 + ); + var service = MakeService(fakeProvider); + var engine = new AiTranslationEngineSettings { ProviderId = "deepl", ApiKey = "key" }; + // Each segment is 9 bytes; a byte cap of 30 should limit each chunk to 3 segments. + var segments = Enumerable.Range(0, 7).Select(i => $"aaaaaaaa{i}").ToArray(); + Assert.That( + segments.All(s => Encoding.UTF8.GetByteCount(s) == 9), + "sanity check: each segment should be 9 bytes" + ); + + var results = await service.TranslateSegmentsAsync( + engine, + segments, + "en", + CancellationToken.None + ); + + Assert.That(fakeProvider.Calls.Count, Is.EqualTo(3)); + Assert.That(fakeProvider.Calls[0].Length, Is.EqualTo(3)); + Assert.That(fakeProvider.Calls[1].Length, Is.EqualTo(3)); + Assert.That(fakeProvider.Calls[2].Length, Is.EqualTo(1)); + Assert.That(results, Is.EqualTo(segments.Select(s => "[fr] " + s))); + } + + [Test] + public async Task TranslateSegmentsAsync_OversizedSingleSegment_GetsItsOwnChunk() + { + var fakeProvider = new FakeAiTranslationProvider( + "deepl", + maxSegmentsPerRequest: 1000, + maxRequestBytes: 5 + ); + var service = MakeService(fakeProvider); + var engine = new AiTranslationEngineSettings { ProviderId = "deepl", ApiKey = "key" }; + var segments = new[] { "short", "this-segment-is-longer-than-the-byte-cap", "short2" }; + + var results = await service.TranslateSegmentsAsync( + engine, + segments, + "en", + CancellationToken.None + ); + + Assert.That(fakeProvider.Calls.Count, Is.EqualTo(3)); + Assert.That(fakeProvider.Calls[1].Single(), Is.EqualTo(segments[1])); + Assert.That(results, Is.EqualTo(segments.Select(s => "[fr] " + s))); + } + + [Test] + public void TranslateSegmentsAsync_WhenProviderReturnsWrongCount_Throws() + { + var fakeProvider = new FakeAiTranslationProvider( + "deepl", + maxSegmentsPerRequest: 50, + maxRequestBytes: 1_000_000, + mismatchCount: true + ); + var service = MakeService(fakeProvider); + var engine = new AiTranslationEngineSettings { ProviderId = "deepl", ApiKey = "key" }; + + Assert.ThrowsAsync(async () => + await service.TranslateSegmentsAsync( + engine, + new[] { "one", "two" }, + "en", + CancellationToken.None + ) + ); + } + + [Test] + public async Task GetSupportedTargetLanguagesAsync_OneEngineFailsToListLanguages_StillReturnsOthers() + { + var service = MakeLanguageListService( + new FakeLanguageListProvider( + "deepl", + throwMessage: "403 Forbidden. Missing required scope(s): languages:read." + ), + new FakeLanguageListProvider("google", languages: new[] { ("es", "Spanish (es)") }) + ); + + var options = await service.GetSupportedTargetLanguagesAsync(CancellationToken.None); + + Assert.That( + options.Count, + Is.EqualTo(1), + "Google's language should still be returned even though DeepL failed to list languages" + ); + Assert.That(options[0].Value, Is.EqualTo("es")); + Assert.That(options[0].ProviderIds, Does.Contain("google")); + Assert.That(options[0].ProviderIds, Does.Not.Contain("deepl")); + } + + [Test] + public void GetSupportedTargetLanguagesAsync_EveryEngineFailsToListLanguages_ThrowsWithCombinedMessage() + { + var service = MakeLanguageListService( + new FakeLanguageListProvider( + "deepl", + throwMessage: "403 Forbidden. Missing required scope(s): languages:read." + ), + new FakeLanguageListProvider("google", throwMessage: "boom") + ); + + var exception = Assert.ThrowsAsync(async () => + await service.GetSupportedTargetLanguagesAsync(CancellationToken.None) + ); + + // Sanity check: with no engine succeeding, the user still gets an informative error + // that names the failing engine and its reason. + Assert.That(exception.Message, Does.Contain("DeepL")); + Assert.That(exception.Message, Does.Contain("languages:read")); + } + + private static AiTranslationService MakeLanguageListService( + params FakeLanguageListProvider[] providers + ) + { + var collectionSettings = new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), + AiTranslationTargetLanguageTag = "fr", + AiTranslationEngines = providers + .Select(p => new AiTranslationEngineSettings + { + ProviderId = p.ProviderId, + Enabled = true, + }) + .ToList(), + }; + return new AiTranslationService( + collectionSettings, + providers.ToDictionary(p => p.ProviderId, p => (IAiTranslationProvider)p) + ); + } + + private static AiTranslationService MakeService(IAiTranslationProvider fakeProvider) + { + var collectionSettings = MakeCollectionSettings(); + return new AiTranslationService( + collectionSettings, + new Dictionary + { + { fakeProvider.ProviderId, fakeProvider }, + } + ); + } + + private static CollectionSettings MakeCollectionSettings() + { + return new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), + AiTranslationTargetLanguageTag = "fr", + }; + } + + private sealed class FakeAiTranslationProvider : IAiTranslationProvider + { + private readonly bool _mismatchCount; + + public FakeAiTranslationProvider( + string providerId, + int maxSegmentsPerRequest, + int maxRequestBytes, + bool mismatchCount = false + ) + { + ProviderId = providerId; + MaxSegmentsPerRequest = maxSegmentsPerRequest; + MaxRequestBytes = maxRequestBytes; + _mismatchCount = mismatchCount; + } + + public string ProviderId { get; } + public int MaxSegmentsPerRequest { get; } + public int MaxRequestBytes { get; } + public List Calls { get; } = new List(); + + public Task> GetSupportedTargetLanguagesAsync( + AiTranslationEngineSettings engine, + HttpClient httpClient, + CancellationToken ct + ) + { + return Task.FromResult(new List()); + } + + public Task TranslateBatchAsync( + AiTranslationEngineSettings engine, + string[] segments, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient, + CancellationToken ct + ) + { + Calls.Add(segments); + if (_mismatchCount) + { + return Task.FromResult(new[] { "only one translation" }); + } + return Task.FromResult( + segments.Select(s => $"[{targetLanguageTag}] " + s).ToArray() + ); + } + } + + /// + /// A provider used only to exercise GetSupportedTargetLanguagesAsync's aggregation: it + /// either returns a fixed set of (tag, name) languages, or throws the given message to + /// simulate an engine that can't list its languages (e.g. a missing API scope). + /// + private sealed class FakeLanguageListProvider : IAiTranslationProvider + { + private readonly (string Tag, string Name)[] _languages; + private readonly string _throwMessage; + + public FakeLanguageListProvider( + string providerId, + (string Tag, string Name)[] languages = null, + string throwMessage = null + ) + { + ProviderId = providerId; + _languages = languages ?? new (string, string)[0]; + _throwMessage = throwMessage; + } + + public string ProviderId { get; } + public int MaxSegmentsPerRequest => 100; + public int MaxRequestBytes => 100_000; + + public Task> GetSupportedTargetLanguagesAsync( + AiTranslationEngineSettings engine, + HttpClient httpClient, + CancellationToken ct + ) + { + if (_throwMessage != null) + throw new HttpRequestException(_throwMessage); + + return Task.FromResult( + _languages + .Select(l => new AiTranslationTargetLanguageOption + { + Value = l.Tag, + Label = l.Name, + ProviderIds = new List { ProviderId }, + }) + .ToList() + ); + } + + public Task TranslateBatchAsync( + AiTranslationEngineSettings engine, + string[] segments, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient, + CancellationToken ct + ) + { + throw new System.NotSupportedException( + "FakeLanguageListProvider is only for supported-languages tests." + ); + } + } + } + + public abstract class AiTranslationLiveTranslationTestsBase + { + private bool _previousAiSourceBubblesEnabled; + + protected abstract string ProviderId { get; } + protected abstract string[] RequiredEnvironmentVariables { get; } + protected abstract AiTranslationEngineSettings MakeEngine(); + + [SetUp] + public void Setup() + { + _previousAiSourceBubblesEnabled = ExperimentalFeatures.IsFeatureEnabled( + ExperimentalFeatures.kAiSourceBubbles + ); + ExperimentalFeatures.SetValue(ExperimentalFeatures.kAiSourceBubbles, true); + } + + [TearDown] + public void TearDown() + { + ExperimentalFeatures.SetValue( + ExperimentalFeatures.kAiSourceBubbles, + _previousAiSourceBubblesEnabled + ); + } + + [Test] + public async Task TranslateSegmentsAsync_ConfiguredProvider_ReturnsTranslationsInOrder() + { + var missingVariables = RequiredEnvironmentVariables + .Where(variableName => + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(variableName)) + ) + .ToArray(); + if (missingVariables.Any()) + { + Assert.Ignore( + $"Manual AI translation provider test. Set {string.Join(", ", missingVariables)} to run it." + ); + } + + var collectionSettings = new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), + AiTranslationTargetLanguageTag = "fr", + }; + var service = new AiTranslationService(collectionSettings); + var engine = MakeEngine(); + var segments = new[] { "Hello world.", "Good morning.", "See you tomorrow." }; + + var results = await service.TranslateSegmentsAsync( + engine, + segments, + "en", + CancellationToken.None + ); + + Assert.That(results.Length, Is.EqualTo(segments.Length)); + for (var i = 0; i < segments.Length; i++) + { + Assert.That(results[i], Is.Not.Null.And.Not.Empty); + Assert.That(results[i], Is.Not.EqualTo(segments[i])); + } + } + } + + [TestFixture] + [Category("SkipOnTeamCity")] + [NonParallelizable] + public class DeepLLiveTranslationTests : AiTranslationLiveTranslationTestsBase + { + protected override string ProviderId => "deepl"; + + protected override string[] RequiredEnvironmentVariables => new[] { "BLOOM_DEEPL_KEY" }; + + protected override AiTranslationEngineSettings MakeEngine() + { + return new AiTranslationEngineSettings + { + ProviderId = "deepl", + ApiKey = Environment.GetEnvironmentVariable("BLOOM_DEEPL_KEY"), + }; + } + } + + [TestFixture] + [Category("SkipOnTeamCity")] + [NonParallelizable] + public class GoogleLiveTranslationTests : AiTranslationLiveTranslationTestsBase + { + protected override string ProviderId => "google"; + + protected override string[] RequiredEnvironmentVariables => + new[] + { + "BLOOM_GOOGLE_TRANSLATION_SERVICE_ACCOUNT_EMAIL", + "BLOOM_GOOGLE_TRANSLATION_SERVICE_PRIVATE_KEY", + }; + + protected override AiTranslationEngineSettings MakeEngine() + { + return new AiTranslationEngineSettings + { + ProviderId = "google", + ServiceAccountEmail = Environment.GetEnvironmentVariable( + "BLOOM_GOOGLE_TRANSLATION_SERVICE_ACCOUNT_EMAIL" + ), + PrivateKey = Environment.GetEnvironmentVariable( + "BLOOM_GOOGLE_TRANSLATION_SERVICE_PRIVATE_KEY" + ), + }; + } + } +} diff --git a/src/BloomTests/AiTranslation/Alpha2TranslationProviderTests.cs b/src/BloomTests/AiTranslation/Alpha2TranslationProviderTests.cs new file mode 100644 index 000000000000..9da47d018b95 --- /dev/null +++ b/src/BloomTests/AiTranslation/Alpha2TranslationProviderTests.cs @@ -0,0 +1,139 @@ +using System; +using System.Linq; +using System.Threading; +using Bloom.AiTranslation; +using Bloom.Collection; +using NUnit.Framework; + +namespace BloomTests.AiTranslation +{ + [TestFixture] + public class Alpha2TranslationProviderTests + { + [Test] + public void MapToIso6393_TwoLetterEnglish_ReturnsEng() + { + Assert.That(Alpha2TranslationProvider.MapToIso6393("en"), Is.EqualTo("eng")); + } + + [Test] + public void MapToIso6393_TwoLetterFrench_ReturnsFra() + { + Assert.That(Alpha2TranslationProvider.MapToIso6393("fr"), Is.EqualTo("fra")); + } + + [Test] + public void MapToIso6393_AlreadyThreeLetterTag_PassesThrough() + { + Assert.That(Alpha2TranslationProvider.MapToIso6393("eng"), Is.EqualTo("eng")); + } + + [Test] + public void MapToIso6393_RegionQualifiedTag_MapsPrimarySubtag() + { + Assert.That(Alpha2TranslationProvider.MapToIso6393("es-ES"), Is.EqualTo("spa")); + } + + [Test] + public void MapToIso6393_UnmappableTag_Throws() + { + var exception = Assert.Throws(() => + Alpha2TranslationProvider.MapToIso6393("zzzzz") + ); + + Assert.That(exception.Message, Does.Contain("zzzzz")); + } + + [Test] + public void IsTranslationComplete_CompleteStatus_ReturnsTrue() + { + Assert.That(Alpha2TranslationProvider.IsTranslationComplete("complete"), Is.True); + } + + [Test] + public void IsTranslationComplete_IsCaseInsensitive() + { + Assert.That(Alpha2TranslationProvider.IsTranslationComplete("COMPLETE"), Is.True); + } + + // The live Alpha2 API reports a queued segment as "pending"; this must be treated as + // still-in-progress, not a failure (regression guard for the "unexpected status 'pending'" + // bug that aborted otherwise-successful translations). + [TestCase("pending")] + [TestCase("running")] + [TestCase("")] + [TestCase(null)] + public void IsTranslationComplete_InProgressStatus_ReturnsFalse(string status) + { + Assert.That(Alpha2TranslationProvider.IsTranslationComplete(status), Is.False); + } + + [Test] + public void IsTranslationComplete_UnrecognizedStatus_Throws() + { + // Sanity check: a status we've never seen is a hard failure, not silently ignored. + var exception = Assert.Throws(() => + Alpha2TranslationProvider.IsTranslationComplete("exploded") + ); + + Assert.That(exception.Message, Does.Contain("exploded")); + } + } + + /// + /// Live round trip against the real Alpha2 service. Gated on BLOOM_ALPHA2_KEY, following the + /// same pattern as the DeepL/Google live tests in AiTranslationServiceTests.cs. + /// + [TestFixture] + [Category("SkipOnTeamCity")] + [NonParallelizable] + public class Alpha2LiveTranslationTests + { + [Test] + public async System.Threading.Tasks.Task TranslateSegmentsAsync_ThreeDistinctSegments_ReturnsTranslationsInOrder() + { + var apiKey = Environment.GetEnvironmentVariable("BLOOM_ALPHA2_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) + { + Assert.Ignore( + "Manual AI translation provider test. Set BLOOM_ALPHA2_KEY to run it." + ); + } + + var collectionSettings = new CollectionSettings + { + Subscription = + Bloom.SubscriptionAndFeatures.Subscription.CreateTempSubscriptionForTier( + Bloom.SubscriptionAndFeatures.SubscriptionTier.Pro + ), + AiTranslationTargetLanguageTag = "es", + }; + var service = new AiTranslationService(collectionSettings); + var engine = new AiTranslationEngineSettings { ProviderId = "alpha2", ApiKey = apiKey }; + + // Distinct number words per segment so we can verify order is preserved, not just + // that translation happened. + var segments = new[] { "I have one cat.", "I have two dogs.", "I have three birds." }; + + var results = await service.TranslateSegmentsAsync( + engine, + segments, + "en", + CancellationToken.None + ); + + Assert.That(results.Length, Is.EqualTo(segments.Length)); + for (var i = 0; i < segments.Length; i++) + { + Assert.That(results[i], Is.Not.Null.And.Not.Empty); + Assert.That(results[i], Is.Not.EqualTo(segments[i])); + } + + // Spanish number words for one/two/three, verifying each translation landed in the + // slot matching its source segment (not merged/reordered). + Assert.That(results[0].ToLowerInvariant(), Does.Contain("uno").Or.Contain("un ")); + Assert.That(results[1].ToLowerInvariant(), Does.Contain("dos")); + Assert.That(results[2].ToLowerInvariant(), Does.Contain("tres")); + } + } +} diff --git a/src/BloomTests/Book/RuntimeInformationInjectorTests.cs b/src/BloomTests/Book/RuntimeInformationInjectorTests.cs index f0289119ecc6..4e108bf35df0 100644 --- a/src/BloomTests/Book/RuntimeInformationInjectorTests.cs +++ b/src/BloomTests/Book/RuntimeInformationInjectorTests.cs @@ -4,7 +4,7 @@ using System.Text; using System.Threading.Tasks; using Bloom; -using Bloom.AiSourceBubbles; +using Bloom.AiTranslation; using Bloom.Book; using Bloom.Collection; using Bloom.SubscriptionAndFeatures; @@ -108,12 +108,10 @@ public void AddLanguagesUsedInPage_InvalidLanguageSubTag_DoesNotThrow(string inv } [Test] - public void AddUISettingsToDom_AiSourceBubblesDisabledWhenValidationFingerprintMissing() + public void AddUISettingsToDom_AiSourceBubblesDisabledWhenNoEngineEnabled() { SetDom("
    "); var collectionSettings = MakeAiSourceBubblesCollectionSettings(); - collectionSettings.AiSourceBubblesLastValidationSucceeded = true; - collectionSettings.AiSourceBubblesValidatedConfigurationFingerprint = "stale"; var bookData = new BookData(_bookDom, collectionSettings, null); RuntimeInformationInjector.AddUISettingsToDom(_bookDom, bookData, null); @@ -125,13 +123,42 @@ public void AddUISettingsToDom_AiSourceBubblesDisabledWhenValidationFingerprintM } [Test] - public void AddUISettingsToDom_AiSourceBubblesEnabledWhenValidationMatchesCurrentConfiguration() + public void AddUISettingsToDom_AiSourceBubblesDisabledWhenValidationFingerprintStale() { SetDom("
    "); var collectionSettings = MakeAiSourceBubblesCollectionSettings(); - collectionSettings.AiSourceBubblesLastValidationSucceeded = true; - collectionSettings.AiSourceBubblesValidatedConfigurationFingerprint = - AiSourceBubblesService.GetConfigurationFingerprint(collectionSettings); + var engine = collectionSettings.AiTranslationEngines.Single(e => + e.ProviderId == "deepl" + ); + engine.Enabled = true; + engine.ApiKey = "sample-key"; + engine.LastValidationSucceeded = true; + engine.ValidatedConfigurationFingerprint = "stale"; + var bookData = new BookData(_bookDom, collectionSettings, null); + + RuntimeInformationInjector.AddUISettingsToDom(_bookDom, bookData, null); + + var scriptContents = _bookDom + .RawDom.SelectSingleNode("//script[@id='ui-settings']") + .InnerText; + Assert.That(scriptContents, Does.Contain("\"allowAiSourceBubbles\":false")); + } + + [Test] + public void AddUISettingsToDom_AiSourceBubblesEnabledWhenAnEngineValidatedAndCurrent() + { + SetDom("
    "); + var collectionSettings = MakeAiSourceBubblesCollectionSettings(); + var engine = collectionSettings.AiTranslationEngines.Single(e => + e.ProviderId == "deepl" + ); + engine.Enabled = true; + engine.ApiKey = "sample-key"; + engine.LastValidationSucceeded = true; + engine.ValidatedConfigurationFingerprint = AiTranslationService.GetEngineFingerprint( + engine, + collectionSettings.AiTranslationTargetLanguageTag + ); var bookData = new BookData(_bookDom, collectionSettings, null); RuntimeInformationInjector.AddUISettingsToDom(_bookDom, bookData, null); @@ -144,13 +171,13 @@ public void AddUISettingsToDom_AiSourceBubblesEnabledWhenValidationMatchesCurren private static CollectionSettings MakeAiSourceBubblesCollectionSettings() { - return new CollectionSettings + var collectionSettings = new CollectionSettings { Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), - AiSourceBubblesProviderId = "deepl", - AiSourceBubblesTargetLanguageTag = "fr", - AiSourceBubblesDeepLApiKey = "sample-key", + AiTranslationTargetLanguageTag = "fr", }; + collectionSettings.EnsureAiTranslationEngines(); + return collectionSettings; } } } From e5711826a44700f2c04e77d846a2d1e41e548d8f Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 14 Jul 2026 17:52:59 -0600 Subject: [PATCH 06/10] Remove experimental --disable-web-security; fix forbidden nested ternary (BL-16549) - WebView2Browser: drop the leftover "--disable-web-security" browser flag that an earlier DeepL experiment added. It disabled web security for the ENTIRE app browser, not just AI translation; AI translation now runs server-side in C#, so the flag is unneeded and reverts to the secure default. (Flagged by Devin.) - BloomSourceBubbles: replace the nested ternary in the source-tab sort comparator with an if-chain, per the repo style rule. (Flagged by Greptile.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bookEdit/sourceBubbles/BloomSourceBubbles.tsx | 4 +++- src/BloomExe/WebView2Browser.cs | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx index 8c8aecc0e78b..724e3d229d11 100644 --- a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx +++ b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx @@ -377,7 +377,9 @@ export default class BloomSourceBubbles { if (indexA >= 0) return -1; if (indexB >= 0) return 1; // Neither in preferred list - maintain alphabetical order - return langA < langB ? -1 : langA > langB ? 1 : 0; + if (langA < langB) return -1; + if (langA > langB) return 1; + return 0; }); return $(itemArray); diff --git a/src/BloomExe/WebView2Browser.cs b/src/BloomExe/WebView2Browser.cs index 5bfe73b93c71..b66b1f43f53b 100644 --- a/src/BloomExe/WebView2Browser.cs +++ b/src/BloomExe/WebView2Browser.cs @@ -395,8 +395,6 @@ private async Task InitWebView() if (!string.IsNullOrEmpty(_uiLanguageOfThisRun)) { additionalBrowserArgs += " --accept-lang=" + _uiLanguageOfThisRun; - // turn off CORS for experiment with DeepL translation - additionalBrowserArgs += " --disable-web-security"; } if (RemoteDebuggingPort.HasValue) { From bc8088059677f63c398873c223aa5f9035161438 Mon Sep 17 00:00:00 2001 From: Hatton Date: Tue, 14 Jul 2026 19:24:37 -0600 Subject: [PATCH 07/10] Address review findings: hoist target-language control, document decisions (BL-16549) - Hoist AiTranslationTargetLanguageControl to module scope, feeding it live data via a React context provided around the Configr pane. Defining it inside the hook gave it a new identity every render, so React remounted it -- closing the target-language dropdown / losing focus whenever another settings field changed. (Devin + Greptile.) - Add the required justification comments to three useEffects per the front-end AGENTS.md rule. (Devin.) - Document (in code) the deliberate decision to leave AI-tab source-language remembering as-is pending a product call. (Devin.) - Plaintext-credentials storage tracked separately as BL-16550. (Devin.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sourceBubbles/BloomSourceBubbles.tsx | 6 + .../collection/AdvancedSettingsPanel.tsx | 309 +++++++++--------- .../collection/AiTranslationSettingsGroup.tsx | 294 ++++++++++------- 3 files changed, 346 insertions(+), 263 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx index 724e3d229d11..b6be05b378d4 100644 --- a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx +++ b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx @@ -39,6 +39,12 @@ export default class BloomSourceBubbles { } private static maybeRememberSourceBubbleLanguage(langTag: string): void { + // Note (BL-16549): this remembers whatever tab was activated as the preferred source + // language, including AI tabs (synthetic tags like "es-x-ai-deepl"). Devin flagged that + // clicking an AI tab therefore displaces the remembered *real* source language. We are + // deliberately leaving that as-is for now: whether an AI tab should be remembered is a + // product decision, not clearly a bug (a user who just chose to view the AI translation may + // well want it to persist). Revisit if that turns out to be undesirable. postString("editView/sourceTextTab", langTag); } diff --git a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx index 0b8ee67209c1..4ddbaca7eec6 100644 --- a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx +++ b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx @@ -16,6 +16,7 @@ import { BloomSubscriptionIndicatorIconAndText } from "../react_components/requi import { useL10n } from "../react_components/l10nHooks"; import { AiTranslationProviderId, + AiTranslationTargetLanguageContext, IAiTranslationEngineValidation, IAiTranslationSettings, IAiTranslationWireSettings, @@ -243,7 +244,10 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { [settings], ); - const aiTranslationSettingsGroup = useAiTranslationSettingsGroup({ + const { + group: aiTranslationSettingsGroup, + targetLanguageData: aiTranslationTargetLanguageData, + } = useAiTranslationSettingsGroup({ settings: aiTranslationFlatSettings, initialValidations: aiTranslationInitialValidations, groupLabel: aiTranslationSectionLabel, @@ -272,175 +276,186 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { `} > {settings && ( - ["initialValues"] - } - themeOverrides={{ - palette: { - primary: { main: kBloomBlue }, - }, - }} - onChange={(newSettings) => { - const normalized = - normalizeConfigrSettings(newSettings); - if (normalized) { - setSettings(normalized); - const aiFlatSettings = - extractAiTranslationFlatSettings( - normalized as unknown as Record< - string, - unknown - >, - ); - const wirePayload = { - ...omitAiTranslationFlatSettings( - normalized as unknown as Record< - string, - unknown - >, - ), - aiTranslation: - buildAiTranslationWirePayload( - aiFlatSettings, - ), - }; - postJson( - "settings/advancedProgramSettings", - wirePayload, - ); - } - }} + // Provide the target-language control's data here, above the Configr pane, so the + // stable module-scope AiTranslationTargetLanguageControl (rendered somewhere inside + // the pane) can read it via context without being redefined each render. + - - - {showAutoUpdate && ( - - )} - - - - - - - {showExperimentalBookSourcesOption && ( + ["initialValues"] + } + themeOverrides={{ + palette: { + primary: { main: kBloomBlue }, + }, + }} + onChange={(newSettings) => { + const normalized = + normalizeConfigrSettings(newSettings); + if (normalized) { + setSettings(normalized); + const aiFlatSettings = + extractAiTranslationFlatSettings( + normalized as unknown as Record< + string, + unknown + >, + ); + const wirePayload = { + ...omitAiTranslationFlatSettings( + normalized as unknown as Record< + string, + unknown + >, + ), + aiTranslation: + buildAiTranslationWirePayload( + aiFlatSettings, + ), + }; + postJson( + "settings/advancedProgramSettings", + wirePayload, + ); + } + }} + > + + + {showAutoUpdate && ( + + )} + + + - )} -
    - {" "} + + + {showExperimentalBookSourcesOption && ( + + )}
    - + {" "} +
    + +
    -
    -
    -
    - +
    + +
    -
    -
    -
    - +
    + +
    -
    -
    - {settings.allowAiSourceBubbles && - aiTranslationSettingsGroup} -
    -
    +
    + {settings.allowAiSourceBubbles && + aiTranslationSettingsGroup} +
    +
    + )} ); diff --git a/src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx b/src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx index 2ee9a9cdeab0..3aed693c42eb 100644 --- a/src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx +++ b/src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx @@ -413,6 +413,11 @@ function useAiTranslationEngineValidation( const latestSettingsRef = React.useRef(settings); latestSettingsRef.current = settings; + // Effect justified: synchronizes this engine's displayed validation with the initialValidation + // prop, which arrives asynchronously from the server (an external source) after the settings + // load. When an up-to-date validation loads we adopt it and seed the "last probed" key so we + // don't immediately re-probe; otherwise we clear it. That is external-state synchronization, + // which is what an Effect is for. React.useEffect(() => { setValidation( initialValidation?.upToDate ? initialValidation : undefined, @@ -429,6 +434,11 @@ function useAiTranslationEngineValidation( // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialValidation]); + // Effect justified: debounced live validation against the external translation service. When + // this engine's enabled state, credentials, or the target language change, we wait ~600ms of + // quiet and then probe the provider over the network and reflect the result, cancelling the + // pending probe (timer) on further change or unmount. Talking to that external service with + // proper cleanup is a legitimate use of an Effect. React.useEffect(() => { if (!isEngineEnabled(settings, spec)) { setIsPending(false); @@ -533,6 +543,151 @@ const EngineValidationStatusControl: React.FunctionComponent<{ ); }; +// The live data the target-language control needs, supplied via context (below) so the control +// can live at module scope. Everything here is derived from the hook's state and refreshed each +// render; the control reads it through the context. +export interface IAiTranslationTargetLanguageControlData { + usesEngineManagedTargetLanguages: boolean; + supportedTargetLanguages: ITargetLanguageOption[]; + supportedLanguagesMessage: string; + isLoadingSupportedLanguages: boolean; + languageOptionsVersion: number; + loadSupportedLanguages: () => Promise; + readyProviderIds: AiTranslationProviderId[]; + engineDisplayNames: Record; +} + +// Provided by AdvancedSettingsPanel (wrapping the Configr pane) so the module-scope +// AiTranslationTargetLanguageControl can reach the hook's live data without being redefined on +// every render. +export const AiTranslationTargetLanguageContext = React.createContext< + IAiTranslationTargetLanguageControlData | undefined +>(undefined); + +// The target-language chooser passed to ConfigrCustomObjectInput's `control` prop. It MUST live at +// module scope (not inside useAiTranslationSettingsGroup): a component defined inside the hook gets +// a new identity on every render, which makes React unmount/remount it -- closing the dropdown and +// losing focus whenever any other settings field changes. It gets its selection via the usual +// value/onChange, and everything else (options, load callback, etc.) from context. +const AiTranslationTargetLanguageControl: React.FunctionComponent<{ + value: string; + disabled?: boolean; + onChange: (value: string) => void; +}> = (controlProps) => { + const data = React.useContext(AiTranslationTargetLanguageContext)!; + + if (!data.usesEngineManagedTargetLanguages) { + return ( + { + controlProps.onChange(event.target.value); + }} + inputProps={{ + "data-testid": "ai-translation-target-language-input", + }} + /> + ); + } + + const currentValue = controlProps.value || ""; + const knownOptions = data.supportedTargetLanguages.some( + (option) => option.value === currentValue, + ) + ? data.supportedTargetLanguages + : currentValue + ? [ + ...data.supportedTargetLanguages, + { + value: currentValue, + label: currentValue, + providerIds: [], + }, + ] + : data.supportedTargetLanguages; + + return ( + // Constrain the width so a long error message wraps within the dialog instead of + // forcing it to scroll horizontally (the message is rendered below, not as the + // TextField's helperText, so we can style it as a red, wrapping error). +
    + { + controlProps.onChange(event.target.value); + }} + SelectProps={{ + onOpen: () => { + void data.loadSupportedLanguages(); + }, + }} + inputProps={{ + "data-testid": "ai-translation-target-language-select", + "data-language-options-version": + data.languageOptionsVersion, + }} + > + + {data.isLoadingSupportedLanguages && ( + + Loading languages... + + )} + {knownOptions.map((option) => { + const note = getLanguageSupportNote( + option, + data.readyProviderIds, + data.engineDisplayNames, + ); + return ( + + {option.label} + {note && ( + + ({note}) + + )} + + ); + })} + + {data.supportedLanguagesMessage && ( +
    + {data.supportedLanguagesMessage} +
    + )} +
    + ); +}; + export const useAiTranslationSettingsGroup = (props: { settings: IAiTranslationSettings | undefined; initialValidations?: Partial< @@ -548,7 +703,10 @@ export const useAiTranslationSettingsGroup = (props: { alpha2EnabledLabel: string; alpha2ApiKeyLabel: string; translationTestLabel: string; -}): React.ReactElement => { +}): { + group: React.ReactElement; + targetLanguageData: IAiTranslationTargetLanguageControlData; +} => { const deepLValidation = useAiTranslationEngineValidation( deepLFieldSpec, props.settings, @@ -613,6 +771,11 @@ export const useAiTranslationSettingsGroup = (props: { } }, [props.settings, supportedTargetLanguages.length]); + // Effect justified: keeps the cached supported-languages list in sync with the engine + // configuration. The list is fetched from the providers (external services) and keyed to a + // specific credential/target configuration; when that configuration changes, the cached list + // and message no longer apply, so we clear them (the refetch is triggered by the effect below). + // This synchronizes cached external data with its inputs, which warrants an Effect. React.useEffect(() => { const currentLanguageConfigKey = getSupportedLanguagesConfigKey( props.settings, @@ -645,123 +808,20 @@ export const useAiTranslationSettingsGroup = (props: { alpha2: props.alpha2EnabledLabel, }; - const AiTranslationTargetLanguageControl: React.FunctionComponent<{ - value: string; - disabled?: boolean; - onChange: (value: string) => void; - }> = (controlProps) => { - if (!usesEngineManagedTargetLanguages) { - return ( - { - controlProps.onChange(event.target.value); - }} - inputProps={{ - "data-testid": "ai-translation-target-language-input", - }} - /> - ); - } - - const currentValue = controlProps.value || ""; - const knownOptions = supportedTargetLanguages.some( - (option) => option.value === currentValue, - ) - ? supportedTargetLanguages - : currentValue - ? [ - ...supportedTargetLanguages, - { - value: currentValue, - label: currentValue, - providerIds: [], - }, - ] - : supportedTargetLanguages; - - return ( - // Constrain the width so a long error message wraps within the dialog instead of - // forcing it to scroll horizontally (the message is rendered below, not as the - // TextField's helperText, so we can style it as a red, wrapping error). -
    - { - controlProps.onChange(event.target.value); - }} - SelectProps={{ - onOpen: () => { - void loadSupportedLanguages(); - }, - }} - inputProps={{ - "data-testid": "ai-translation-target-language-select", - "data-language-options-version": languageOptionsVersion, - }} - > - - {isLoadingSupportedLanguages && ( - - Loading languages... - - )} - {knownOptions.map((option) => { - const note = getLanguageSupportNote( - option, - readyProviderIds, - engineDisplayNames, - ); - return ( - - {option.label} - {note && ( - - ({note}) - - )} - - ); - })} - - {supportedLanguagesMessage && ( -
    - {supportedLanguagesMessage} -
    - )} -
    - ); + // Bundle the control's live inputs for the context. The control itself is a stable module-scope + // component (see AiTranslationTargetLanguageControl); this data is what changes over time. + const targetLanguageData: IAiTranslationTargetLanguageControlData = { + usesEngineManagedTargetLanguages, + supportedTargetLanguages, + supportedLanguagesMessage, + isLoadingSupportedLanguages, + languageOptionsVersion, + loadSupportedLanguages, + readyProviderIds, + engineDisplayNames, }; - return ( + const group = ( ); + + return { group, targetLanguageData }; }; From 4e14f6d12be67570f25d8cdab1abaf3286945205 Mon Sep 17 00:00:00 2001 From: Hatton Date: Wed, 15 Jul 2026 15:49:25 -0600 Subject: [PATCH 08/10] AI Source Translation: language-support UX, provider logos, fixes (BL-16549) Refines the AI source-translation settings and translation flow: - Rename the feature UI to "AI Source Translation"; add provider brand marks (DeepL, Google, SIL) next to target languages and enable labels. - Add a SIL Alpha2 source-language chooser and surface, per engine, when the chosen target language isn't supported ("will be skipped"), plus guidance when no provider is selected or no service supports a language. - Resilient supported-languages listing: one engine failing no longer blanks the target-language dropdown or blocks translation. - Fix: Google credentials were written onto every engine's wire record, wiping other engines' validation when Google credentials were edited. - Skip Alpha2 text boxes that lack source-language text, reporting the count in the progress dialog. - Add missing GoogleServiceAccountDescription XLF entry (was referenced in code with only an English fallback) and translator context notes. Includes host-side unit tests for the scanner, updater, service, and Alpha2 provider. Co-Authored-By: Claude Opus 4.8 (1M context) --- DistFiles/localization/en/Bloom.xlf | 52 +- PAPERCUTS.md | 37 + .../collection/AdvancedSettingsPanel.test.tsx | 125 ++- .../collection/AdvancedSettingsPanel.tsx | 56 +- .../collection/AiTranslationProviderLogos.tsx | 62 ++ .../collection/AiTranslationSettingsGroup.tsx | 827 ++++++++++++++---- .../AiTranslation/AiTranslationBookScanner.cs | 276 +++++- .../AiTranslation/AiTranslationBookUpdater.cs | 31 +- .../AiTranslationEngineSettings.cs | 27 + .../AiTranslation/AiTranslationService.cs | 162 +++- .../Alpha2TranslationProvider.cs | 265 +++++- .../AiTranslation/DeepLTranslationProvider.cs | 2 + .../GoogleTranslationProvider.cs | 2 + .../AiTranslation/IAiTranslationProvider.cs | 8 + src/BloomExe/Collection/CollectionSettings.cs | 17 + .../web/controllers/CollectionSettingsApi.cs | 110 ++- .../AiTranslationBookScannerTests.cs | 155 ++++ .../AiTranslationBookUpdaterTests.cs | 114 +++ .../AiTranslationServiceTests.cs | 200 +++++ .../Alpha2TranslationProviderTests.cs | 79 ++ 20 files changed, 2355 insertions(+), 252 deletions(-) create mode 100644 src/BloomBrowserUI/collection/AiTranslationProviderLogos.tsx diff --git a/DistFiles/localization/en/Bloom.xlf b/DistFiles/localization/en/Bloom.xlf index a26202fecd25..cb9f765f3244 100644 --- a/DistFiles/localization/en/Bloom.xlf +++ b/DistFiles/localization/en/Bloom.xlf @@ -380,12 +380,14 @@ This is the label of a checkbox which, if enabled, allows a menu command to be used that can import Bloom books from an Excel spreadsheet or to an Excel spreadsheet. - AI Source Bubbles + AI Source Translation ID: CollectionSettingsDialog.AdvancedTab.Experimental.AiSourceBubbles + Label of the experimental-features checkbox that turns on AI source-language translation for book source bubbles. - AI Source Bubbles + AI Source Translation ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.SectionLabel + Heading of the settings group where the AI source-translation providers (SIL Alpha2, DeepL, Google Translate) are configured. Target Language @@ -407,8 +409,14 @@ Label of the checkbox that enables the DeepL translation engine for AI source bubbles. "DeepL" is a product name and must not be translated. - DeepL API key + API Key ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLApiKeyLabel + Label of the API-key field within the DeepL section of AI Source Translation settings (the section heading already identifies it as DeepL). + + + The key needs the 'translate:text' and 'languages:read' permissions. + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLApiKeyDescription + Description subtitle under the DeepL API key field in AI Source Translation settings. 'translate:text' and 'languages:read' are DeepL permission-scope identifiers and must not be translated. Google Translate @@ -423,20 +431,51 @@ Google private key ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GooglePrivateKeyLabel + + The service account needs access to the Cloud Translation API (the 'Cloud Translation API User' role). + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GoogleServiceAccountDescription + Description subtitle under the Google service account fields in AI Source Translation settings. 'Cloud Translation API' and 'Cloud Translation API User' are Google product/role names and must not be translated. + SIL Alpha2 ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2EnabledLabel Label of the checkbox that enables the SIL Alpha2 translation engine for AI source bubbles. "SIL Alpha2" is a product name and must not be translated. - Alpha2 API key + API Key ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2ApiKeyLabel + Label of the API-key field within the SIL Alpha2 section of AI Source Translation settings (the section heading already identifies it as SIL Alpha2). Translation Test ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TranslationTestLabel Label of the row showing a live translation-test result for one AI source bubbles engine (DeepL, Google Translate, or SIL Alpha2); this same label is reused once per engine. + + Source Language + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2SourceLanguageLabel + Label of the SIL Alpha2 source-language chooser in AI Source Bubbles settings: the language Alpha2 translates FROM (Alpha2 needs a fixed source language, unlike the other engines). + + + {0} does not support translating to {1}, so it will be skipped. + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TargetLanguageNotSupported + Amber note under an AI Source Bubbles engine when it can't translate into the chosen target language. {0} is the translation engine's name (e.g. "DeepL"), which must not be translated; {1} is the target language name. + + + no enabled service supports this + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.NoServiceSupportsLanguage + Shown in parentheses next to the currently-selected target language in the AI Source Bubbles target-language dropdown when none of the enabled translation services offers that language. + + + Select at least one translation provider to get a list of languages it supports. + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.NoProviderSelected + Message shown below the (disabled) AI Source Bubbles target-language field when no translation provider is enabled yet, so there is no language list to choose from. + + + SIL Alpha2 shows the languages it can translate from {0}. + ID: CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2LanguagesNote + Explanatory note below the AI Source Bubbles target-language dropdown, shown when SIL Alpha2 is an enabled provider. It clarifies that Alpha2's offered languages depend on its configured source language. {0} is the source language's name (e.g. "English"); "SIL Alpha2" is a product name and must not be translated. + Bloom is switching the default font for "{0}" to the new "Andika New Basic". ID: CollectionSettingsDialog.AndikaNewBasicUpdate1 @@ -1422,6 +1461,11 @@ ID: EditTab.AiTranslation.EngineError Error line in the AI translation dialog. {0} is the translation engine's name (e.g. "DeepL"); {1} is an error message. Only the separator punctuation can be localized. + + {0}: skipped {1} text box(es) with no {2} text. + ID: EditTab.AiTranslation.EngineSkippedNoSourceText + Progress line in the AI translation dialog, shown when an engine with a fixed source language (e.g. SIL Alpha2) had to skip text boxes that have no text in that language. {0} is the translation engine's name (e.g. "SIL Alpha2"), which must not be translated; {1} is the number of text boxes skipped; {2} is the source language tag (e.g. "en"). + {0}: translating {1} text box(es)... ID: EditTab.AiTranslation.EngineTranslating diff --git a/PAPERCUTS.md b/PAPERCUTS.md index 51a168af2c8d..f476e6d62bff 100644 --- a/PAPERCUTS.md +++ b/PAPERCUTS.md @@ -2,6 +2,29 @@ Small dev/agent/tooling friction points captured mid-task (see the `papercut` skill). +## 2026-07-15 — Orphaned `node.exe` dev stacks pile up until `go.sh` can't launch +- **Cut:** Each `go.sh` spawns a dev stack (`dev.mjs` → vite + ~7 `onchange`/`watchLess` + watchers + `watchBloomExe`). On Windows, closing a terminal or Ctrl-C doesn't reliably tear + down that child tree, so stacks orphan across worktrees/sessions and `node.exe` accumulates + (saw ~40 live: 3 worktrees + a dead-parent chrome-devtools-mcp daemon). Under that load + `go.mjs`'s Vite health gate times out — it needs **2 consecutive sub-3s** `/@vite/client` + responses, and Vite binds **IPv6-only (`[::1]`, 127.0.0.1 refused)** on this machine, so + there's zero margin — and every launch fails with "Vite … never became reachable," i.e. it + "piles up until none work." `go.mjs`'s startup sweep only reaps *this* worktree's stale + procs, and the chrome-devtools-mcp daemon has a **watchdog that respawns it after kill** + (self-healing zombie). +- **Idea:** Add a zombie-reaper keyed on *liveness of the controller*, not worktree path: a + vite/watcher subtree with no living `go.mjs`/`watchBloomExe` ancestor (or a proc whose + parent is dead) is a zombie → auto-kill. Run it on `go.sh` startup across **all** Bloom + worktree stacks (safe because it never touches subtrees with a live controller), and/or ship + a standalone `pnpm reap`. Separately harden the health probe so a slow-but-listening Vite + passes: bind Vite on 127.0.0.1 too, accept a single success, and scale the timeout with + detected load. For chrome-devtools-mcp, kill the watchdog first (else it resurrects the + daemon) — memory note already says prefer the CLI over the MCP. +- **Context:** BL-16549AiSourceBubbles; repeated `./go.sh` failures. See `go.mjs` + `waitForViteClient` / `startDevServerOnPort` and `processTree.mjs` + `sweepStaleWorktreeNodeProcesses`. + - Running `dotnet test` (or any BloomExe build) while a `./go.sh` / `dotnet watch` Bloom is live fails at the copy-to-output step: the running process locks both `output/Debug/AnyCPU/Bloom.exe` (native apphost) and, once hot-reload deltas have @@ -10,3 +33,17 @@ Small dev/agent/tooling friction points captured mid-task (see the `papercut` sk Workaround that neither kills the running instance nor touches the locked output: redirect the whole build to a scratch dir and skip the apphost, e.g. `dotnet test src/BloomTests/BloomTests.csproj --filter ... -p:UseAppHost=false -p:OutDir=`. + +## 2026-07-15 — config-r draws a divider between every direct group child; label is string-typed +- **Cut:** `@sillsdev/config-r`'s `ConfigrGroup` (in a focused page) inserts a horizontal + divider between *every* direct child of the group. So an engine block written as a + `` followed by a separate `{enabled && <>...fields...}` gets an unwanted + line between the checkbox and its own settings. Also, `IConfigrProps.label` is typed `string`, + so you can't cleanly put a logo/node before a label. +- **Workaround:** Wrap each engine's checkbox + conditional fields in a single fragment so the + group sees one child per engine (dividers land only *between* engines). For a logo-in-label, + pass a ReactNode cast `as unknown as string` — config-r renders `label` straight into MUI + `ListItemText` `primary`, which accepts a node, so it works at runtime. +- **Idea:** Ask config-r for a `label?: React.ReactNode` type and/or a per-row `hideDivider` + (or a "subgroup" that suppresses internal dividers). See `AiTranslationSettingsGroup.tsx`. +- **Context:** BL-16549AiSourceBubbles AI Source Bubbles settings. diff --git a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx index 9a71e85ddd5a..f822dfcb77ac 100644 --- a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx +++ b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.test.tsx @@ -207,6 +207,21 @@ vi.mock("@sillsdev/config-r", () => ({ > Enable All Three + {props.children} ); @@ -250,10 +265,7 @@ vi.mock("@sillsdev/config-r", () => ({ })); import { AdvancedSettingsPanel } from "./AdvancedSettingsPanel"; -import { - getLanguageSupportNote, - parseSupportedTargetLanguageOptions, -} from "./AiTranslationSettingsGroup"; +import { parseSupportedTargetLanguageOptions } from "./AiTranslationSettingsGroup"; describe("AdvancedSettingsPanel", () => { let container: HTMLDivElement; @@ -355,6 +367,12 @@ describe("AdvancedSettingsPanel", () => { }; } + if ( + endpoint === "settings/aiTranslationAlpha2SourceLanguages" + ) { + return { data: { languages: [] } }; + } + throw new Error(`Unexpected async POST endpoint: ${endpoint}`); }, ); @@ -615,32 +633,85 @@ describe("AdvancedSettingsPanel", () => { ).toEqual([{ value: "es", label: "Spanish", providerIds: ["deepl"] }]); }); - it("notes when a language is only supported by some of the ready engines", () => { - const spanish = { - value: "es", - label: "Spanish", - providerIds: ["deepl", "google"] as const, - }; - const french = { - value: "fra", - label: "French", - providerIds: ["deepl"] as const, - }; - const displayNames = { - deepl: "DeepL", - google: "Google Translate", - alpha2: "SIL Alpha2", - }; + it("round-trips alpha2 sourceLanguageTag through the wire payload", async () => { + await act(async () => { + ReactDOM.render(, container); + }); + + click('[data-testid="enable-alpha2-with-source"]'); + expect(mockPostJson).toHaveBeenCalledWith( + "settings/advancedProgramSettings", + expect.objectContaining({ + aiTranslation: expect.objectContaining({ + engines: expect.arrayContaining([ + expect.objectContaining({ + providerId: "alpha2", + enabled: true, + apiKey: "alpha2-key", + sourceLanguageTag: "fr", + }), + ]), + }), + }), + ); + // The non-alpha2 engines must send an empty source language. + const [, wirePayload] = mockPostJson.mock.calls[0]; + const engines = ( + wirePayload as { + aiTranslation: { + engines: Array<{ + providerId: string; + sourceLanguageTag: string; + }>; + }; + } + ).aiTranslation.engines; expect( - getLanguageSupportNote(spanish, ["deepl", "google"], displayNames), + engines.find((e) => e.providerId === "deepl")?.sourceLanguageTag, ).toBe(""); - expect( - getLanguageSupportNote(french, ["deepl", "google"], displayNames), - ).toBe("DeepL"); - // Supported by none of the ready engines: no note (won't appear as an option anyway). - expect(getLanguageSupportNote(french, ["google"], displayNames)).toBe( - "", + }); + + it("shows the amber 'will be skipped' note when an engine doesn't support the target", async () => { + mockPostJsonAsync.mockImplementation( + async (endpoint: string, body?: unknown) => { + if (endpoint === "settings/validateAiTranslationEngine") { + const providerId = (body as { providerId: string }) + .providerId; + return { + data: { + succeeded: false, + targetLanguageNotSupported: true, + message: `${providerId} cannot do this language`, + }, + }; + } + if (endpoint === "settings/aiTranslationSupportedLanguages") { + return { data: { languages: [] } }; + } + if ( + endpoint === "settings/aiTranslationAlpha2SourceLanguages" + ) { + return { data: { languages: [] } }; + } + throw new Error(`Unexpected async POST endpoint: ${endpoint}`); + }, + ); + + await act(async () => { + ReactDOM.render(, container); + }); + + click('[data-testid="enable-google"]'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(601); + }); + + expect(container.textContent).toContain( + "does not support translating to", ); + // The red "Translation test failed" text must NOT be shown for the not-supported case. + expect(container.textContent).not.toContain("Translation test failed"); }); }); diff --git a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx index 4ddbaca7eec6..1ccc41ff761f 100644 --- a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx +++ b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx @@ -16,7 +16,7 @@ import { BloomSubscriptionIndicatorIconAndText } from "../react_components/requi import { useL10n } from "../react_components/l10nHooks"; import { AiTranslationProviderId, - AiTranslationTargetLanguageContext, + AiTranslationLanguageControlsContext, IAiTranslationEngineValidation, IAiTranslationSettings, IAiTranslationWireSettings, @@ -109,11 +109,11 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { "CollectionSettingsDialog.AdvancedTab.Experimental.AppBuilder", ); const aiSourceBubblesLabel = useL10n( - "AI Source Bubbles", + "AI Source Translation", "CollectionSettingsDialog.AdvancedTab.Experimental.AiSourceBubbles", ); const aiTranslationSectionLabel = useL10n( - "AI Source Bubbles", + "AI Source Translation", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.SectionLabel", ); const aiTranslationTargetLanguageLabel = useL10n( @@ -125,9 +125,13 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLEnabledLabel", ); const aiTranslationDeepLApiKeyLabel = useL10n( - "DeepL API Key", + "API Key", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLApiKeyLabel", ); + const aiTranslationDeepLApiKeyDescription = useL10n( + "The key needs the 'translate:text' and 'languages:read' permissions.", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.DeepLApiKeyDescription", + ); const aiTranslationGoogleEnabledLabel = useL10n( "Google Translate", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GoogleEnabledLabel", @@ -136,6 +140,10 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { "Google Service Account Email", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GoogleServiceAccountEmailLabel", ); + const aiTranslationGoogleServiceAccountDescription = useL10n( + "The service account needs access to the Cloud Translation API (the 'Cloud Translation API User' role).", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GoogleServiceAccountDescription", + ); const aiTranslationGooglePrivateKeyLabel = useL10n( "Google Service Account Private Key", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.GooglePrivateKeyLabel", @@ -145,13 +153,33 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2EnabledLabel", ); const aiTranslationAlpha2ApiKeyLabel = useL10n( - "Alpha2 API key", + "API Key", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2ApiKeyLabel", ); + const aiTranslationAlpha2SourceLanguageLabel = useL10n( + "Source Language", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2SourceLanguageLabel", + ); const aiTranslationTranslationTestLabel = useL10n( "Translation Test", "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TranslationTestLabel", ); + const aiTranslationTargetLanguageNotSupportedTemplate = useL10n( + "{0} does not support translating to {1}, so it will be skipped.", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.TargetLanguageNotSupported", + ); + const aiTranslationNoServiceSupportsLanguageNote = useL10n( + "no enabled service supports this", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.NoServiceSupportsLanguage", + ); + const aiTranslationNoProviderSelectedNote = useL10n( + "Select at least one translation provider to get a list of languages it supports.", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.NoProviderSelected", + ); + const aiTranslationAlpha2LanguagesNoteTemplate = useL10n( + "SIL Alpha2 shows the languages it can translate from {0}.", + "CollectionSettingsDialog.AdvancedTab.AiSourceBubbles.Alpha2LanguagesNote", + ); const qrCodesLabel = useL10n( "QR Codes", "CollectionSettingsDialog.AdvancedTab.QrCodes", @@ -246,7 +274,7 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { const { group: aiTranslationSettingsGroup, - targetLanguageData: aiTranslationTargetLanguageData, + languageControlsData: aiTranslationLanguageControlsData, } = useAiTranslationSettingsGroup({ settings: aiTranslationFlatSettings, initialValidations: aiTranslationInitialValidations, @@ -254,13 +282,23 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { targetLanguageLabel: aiTranslationTargetLanguageLabel, deepLEnabledLabel: aiTranslationDeepLEnabledLabel, deepLApiKeyLabel: aiTranslationDeepLApiKeyLabel, + deepLApiKeyDescription: aiTranslationDeepLApiKeyDescription, googleEnabledLabel: aiTranslationGoogleEnabledLabel, googleServiceAccountEmailLabel: aiTranslationGoogleServiceAccountEmailLabel, + googleServiceAccountDescription: + aiTranslationGoogleServiceAccountDescription, googlePrivateKeyLabel: aiTranslationGooglePrivateKeyLabel, alpha2EnabledLabel: aiTranslationAlpha2EnabledLabel, alpha2ApiKeyLabel: aiTranslationAlpha2ApiKeyLabel, + alpha2SourceLanguageLabel: aiTranslationAlpha2SourceLanguageLabel, translationTestLabel: aiTranslationTranslationTestLabel, + targetLanguageNotSupportedTemplate: + aiTranslationTargetLanguageNotSupportedTemplate, + noServiceSupportsLanguageNote: + aiTranslationNoServiceSupportsLanguageNote, + noProviderSelectedNote: aiTranslationNoProviderSelectedNote, + alpha2LanguagesNoteTemplate: aiTranslationAlpha2LanguagesNoteTemplate, }); return ( @@ -279,8 +317,8 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { // Provide the target-language control's data here, above the Configr pane, so the // stable module-scope AiTranslationTargetLanguageControl (rendered somewhere inside // the pane) can read it via context without being redefined each render. - { aiTranslationSettingsGroup} - + )} ); diff --git a/src/BloomBrowserUI/collection/AiTranslationProviderLogos.tsx b/src/BloomBrowserUI/collection/AiTranslationProviderLogos.tsx new file mode 100644 index 000000000000..c471133560ed --- /dev/null +++ b/src/BloomBrowserUI/collection/AiTranslationProviderLogos.tsx @@ -0,0 +1,62 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { AiTranslationProviderId } from "./AiTranslationSettingsGroup"; + +// Small provider-brand marks shown next to each language in the target-language dropdown to +// indicate which enabled services offer that language (in place of spelling the names out). +// The DeepL and Google marks are the services' own icons; the Alpha2 mark is SIL's glyph. All are +// embedded as data URIs so the panel stays self-contained (no bundler asset-path concerns). + +// DeepL's icon (deepl-24x24.png). +const kDeepLLogoDataUri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAAARhJREFUeAG1lIENgjAQRX+MAzgCbsAGMAIbyAboBOIGboAb4AbECXADdALcAO9CEdK0cBT8yQuQ0PvN9fcAuSKiUhywokKiIBqNkvCwQDviaiisk7kYHYlaULyjUmsmFaqfG0dGzydeUFjnZ7IZGAQW4yfaHqeQKzQZ2MS9fRMX4oWZkhhE6ukTJxeTThnkhxir9xLmUGSuBkMjX63j+1LbDCQtMslDH4oP2iAYtcV8ccEUfVL2g3cnAy54Q3vYd+JBJKpoNLVYGlNOz1595xjZsYtBJw/CeWNTCHNiOCG86wqyhPH/0dgu58RVp4BwdAeYN1VL2GfZqOIJI25HgoXyYG/b4uK6UQ7L3F9TMfq2/cWg0xmCC/cFAYS4WXqkl2cAAAAASUVORK5CYII="; + +// Google Translate's icon (google-24x24.png). +const kGoogleLogoDataUri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAAAtlJREFUeAGtVU1ME1EQnnm7NaJpKBETJCLLrerBlijhYHQBPZiYWA8mxhApZzXi1cS0jVw8qQdOHrZ6ES9aE4k/CSy9eSBh40XjhQUTCzGBTUCp3e6Ob2mRpezSJfBddjPz5vvmzXvzBqEOfnRHZSK8ggQSAsQcGxHpgGggUM5kkO/4/E33i0c/x9yZaJIhpghAgjogwKzF7IyX0BaBQndUKhMqPEqGnYDAENqOZVpff3ziK+CQWzaqQbL21gDdKq3GOzTdWLexwOSETpDGU5rEcNjwJqceN7kDcf3HshmvN0legfuiJ4dWv0zl3cGFGE8oBClCTG6Q+5xBof+oXP4eVrc4CbOm+edebVZuzHUdT3ByzYv8v0BxAtTiVKO88q4FqFitmg25tqmvV2GXwFWV328bZtY4l0JgPGsHi3/LJerwy2onEJkFMareJdZkQtOdGfj9/nC2eWRe9wq4dv9TTECMQBCIoi7yIp1y27DBgnBiPgcj3jGCID7mRy9DEFh22im4VGs3CWZhL4AgMS/7QQGWYI/gCOi1xpJdedR2C94fhmgDzLrfi2VbhLGVVpnf8LeeUbaVQRCzW8iYzWMgudlIOpIKkb92pSSFcgPcWuiEgrXfIAjFtcEPOgTE9QfjSq0AAsoMe8Bw3pexlSMwUOhyyB1fBMFUApOnVKmWnEN/+bA3v3bItxdOZ4YXT8Ayie4FcqfSV1dkjZxI9XBNVnZRRVzpe8M/CY+FOjFIawPjz93GmCJHGIhDYrnxbtvPG5FQeaP3OKlBpVJ89NElHd0BCGyauyXwBWnOqASnd2ijf0JmI7T8ugwHiu2VVURDr4YvPK2KbcqKz12mbi/ij0NLZ6F58Vx6dLgv49rNZlREBKf2MuwQfJ6ktcGJjNvmO/RjSm+SO1NBdsMbahIFOz19U83X+rBecPzFxfNkWwk+Mnl3Ez9J5yWlyvhkqKFAuen+Cd+36x/CsiwGSaBwpgAAAABJRU5ErkJggg=="; + +// SIL's glyph (tiny-SIL.svg), used for Alpha2 in the compact dropdown. +const kSilGlyphDataUri = + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjY4IiBoZWlnaHQ9Ijc1MSIgdmlld0JveD0iMCAwIDY2OCA3NTEiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik00NDAuOTggNTYzLjkzVjI3Mi4zNEw1MDIuMjYgMjcyLjMyVjUwNi4yN0w2NjggNTA2LjQxVjBIMC4wMTAwMDk4VjUwNi40MkwxNjYuOTcgNTA2LjM3QzIwMS42NSA1MDYuMzcgMjA5LjIxIDQ5Mi4zOCAyMDkuNDcgNDgwLjYzQzIwOS45OSA0NTYuNTggMTk2LjkxIDQ0Ni45NyAxNzEuNzEgNDMwLjZDMTI0LjQyIDQwMS43OCAxMTcuMzYgMzY4LjE0IDExNy4zNiAzNTAuNjZDMTE3LjM2IDI5MS4wNCAxNjIuNzggMjY0LjM2IDIwNy43NyAyNjQuMzZDMjI1LjU3IDI2NC4zNiAyNTUuMTkgMjcxLjMyIDI2NS44MiAyNzYuMDNMMjU1LjYzIDMzMC4wNEMyNDcuNTMgMzI1LjU4IDIzNi4xMiAzMjEuMDIgMjIxLjAzIDMyMS4wMkMxOTMuNDkgMzIxLjAyIDE4My42NyAzMzUuMDEgMTgzLjY3IDM0OC4xMkMxODMuNjcgMzY4LjU1IDE5My41NyAzNzUuNDggMjI0Ljg0IDM5NC4yOUMyNTkuNDQgNDE1LjI5IDI3Ni45OCA0NDIuOTQgMjc2Ljk4IDQ3Ni40NEMyNzYuOTggNTI3Ljk4IDIzOC45OSA1NjIuNjggMTgyLjQ3IDU2Mi44MkwwIDU2Mi45Vjc1MC4zNUg2NjcuOTlWNTYzLjkzSDQ0MC45OFpNMzgxLjM0IDU2My45M0gzMjMuODZWMjcyLjUySDM4MS4zNFY1NjMuOTNaIiBmaWxsPSIjMjE1RUFDIi8+Cjwvc3ZnPgo="; + +// SIL's full "SIL" wordmark with the Tai Heritage Pro script sample (SIL-Blue-Tai-Heritage.svg), +// shown before the Alpha2 enable label. +const kSilWordmarkDataUri = + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTM5NSIgaGVpZ2h0PSI3NTIiIHZpZXdCb3g9IjAgMCAxMzk1IDc1MiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzQyNzRfMTM2KSI+CjxwYXRoIGQ9Ik02NjcuNTQgMC44MzAwNzhINTgyLjYxSDAuODkwMTM3VjI1OS42MVY0NDcuNjVWNzUwLjc4SDY2OC4zNVYwLjgzMDA3OEg2NjcuNTRaIiBmaWxsPSIjMEVBNURGIi8+CjxwYXRoIGQ9Ik00MC43NzAxIDM0Mi41NkM1My44MzAxIDMxNiA2Ny40MDAxIDI5My4wNCA4MS41MTAxIDI3My42OEM5NS42MTAxIDI1NC4zMiAxMDkuNzkgMjM5LjI0IDEyNC4wNSAyMjguNDRDMTM4LjMgMjE3LjY0IDE1MS43NCAyMTIuMjMgMTY0LjM0IDIxMi4yM0MxNzAuMDQgMjEyLjIzIDE3NC44NCAyMTMuMjggMTc4Ljc1IDIxNS4zOEMxODIuNjUgMjE3LjQ4IDE4NS44IDIyMC4zMyAxODguMiAyMjMuOTNDMTkwLjYgMjI3LjUzIDE5Mi4zMiAyMzEuNTggMTkzLjM4IDIzNi4wOEMxOTQuNDMgMjQwLjU4IDE5NC45NiAyNDUuMjQgMTk0Ljk2IDI1MC4wNEMxOTQuOTYgMjc0LjA1IDE5My4zMSAyOTcuODMgMTkwLjAxIDMyMS4zOUMxODYuNyAzNDQuOTUgMTgzLjEgMzY4LjUxIDE3OS4yMSAzOTIuMDdDMTc1LjMxIDQxNS42MyAxNzEuNzEgNDM5LjE5IDE2OC40MSA0NjIuNzVDMTY1LjEgNDg2LjMxIDE2My40NiA1MTAuMSAxNjMuNDYgNTM0LjFDMTYzLjQ2IDU0OS4xMSAxNjUuMjYgNTYzLjI5IDE2OC44NiA1NzYuNjRDMTcyLjQ2IDU5MCAxNzguMDEgNjAxLjc4IDE4NS41MiA2MTEuOThDMTkzLjAyIDYyMi4xOSAyMDIuNjMgNjMwLjIxIDIxNC4zMyA2MzYuMDZDMjI2LjA0IDY0MS45MSAyNDAuMTQgNjQ0Ljg0IDI1Ni42NSA2NDQuODRDMjcwLjc1IDY0NC44NCAyODQuMTggNjQyLjgxIDI5Ni45NCA2MzguNzZDMzA5LjY5IDYzNC43MSAzMjEuOTIgNjI5LjUzIDMzMy42MyA2MjMuMjNDMzQ1LjMzIDYxNi45MyAzNTYuNzQgNjA5Ljk1IDM2Ny44NCA2MDIuM0MzNzguOTQgNTk0LjY1IDM4OS44MiA1ODcuMzcgNDAwLjQ4IDU4MC40N0M0MTEuMTMgNTczLjU3IDQyMS43MSA1NjcuNDIgNDMyLjIyIDU2Mi4wMUM0NDIuNzIgNTU2LjYxIDQ1My4zOCA1NTIuODYgNDY0LjE4IDU1MC43NkM0NjkuMjggNTYyLjE3IDQ3NS41IDU3My4yNyA0ODIuODYgNTg0LjA3QzQ5MC4yMSA1OTQuODcgNDk4LjY5IDYwNC40OCA1MDguMjkgNjEyLjg4QzUxNy44OSA2MjEuMjkgNTI4LjQ3IDYyOC4xMiA1NDAuMDMgNjMzLjM2QzU1MS41OCA2MzguNjEgNTY0LjExIDY0MS4yNCA1NzcuNjIgNjQxLjI0QzU5Ny4xMiA2NDEuMjQgNjExLjQ1IDYzNS42MSA2MjAuNjEgNjI0LjM2QzYyOS43NiA2MTMuMTEgNjM0LjM0IDU5OC4xOCA2MzQuMzQgNTc5LjU3QzYzNC4zNCA1NjYuMDcgNjMwLjY2IDU1My41NCA2MjMuMzEgNTQxLjk4QzYxNS45NSA1MzAuNDMgNjA2LjY1IDUyMC4zIDU5NS40IDUxMS41OUM1ODQuMTUgNTAyLjg5IDU3MS44NCA0OTUuOTEgNTU4LjQ5IDQ5MC42NkM1NDUuMTMgNDg1LjQxIDUzMi40NSA0ODIuNDkgNTIwLjQ1IDQ4MS44OEM1MTkuODUgNDcwLjQ4IDUxOS4zMiA0NTkuMjIgNTE4Ljg3IDQ0OC4xMkM1MTguNDIgNDM3LjAyIDUxOC4xOSA0MjYuMjIgNTE4LjE5IDQxNS43MUM1MTguMTkgMzY0LjY5IDUyMi45MiAzMTcuMiA1MzIuMzcgMjczLjIzQzU0MS44MiAyMjkuMjcgNTU2LjUyIDE4Ni40MiA1NzYuNDkgMTQ0LjcxQzU5Ni40NSAxMDMgNjIyLjAzIDYxLjI4MDEgNjUzLjI0IDE5LjU2MDFDNjU3Ljg3IDEzLjM3MDEgNjYyLjY1IDcuMTIwMDggNjY3LjU1IDAuODMwMDc4SDU4Mi42MkM1NTkuNDkgMzIuMTMwMSA1MzkuMTMgNjQuODYwMSA1MjEuNTYgOTkuMDEwMUM0OTcuNCAxNDUuOTggNDc4Ljg2IDE5NS43MyA0NjUuOTYgMjQ4LjI0QzQ1My4wNSAzMDAuNzYgNDQ2LjYgMzU2LjEzIDQ0Ni42IDQxNC4zNUM0NDYuNiA0MzcuMTYgNDQ3LjUgNDYwLjI3IDQ0OS4zIDQ4My42OEM0MzguNzkgNDg1LjQ4IDQyNy43NiA0ODguODYgNDE2LjIxIDQ5My44MUM0MDQuNjUgNDk4Ljc2IDM5My4wMyA1MDQuMzkgMzgxLjMyIDUxMC42OUMzNjkuNjIgNTE2Ljk5IDM1OC4xNCA1MjMuNiAzNDYuODggNTMwLjVDMzM1LjYzIDUzNy40MSAzMjUuMDUgNTQzLjcxIDMxNS4xNCA1NDkuNDFDMzA1LjI0IDU1NS4xMSAyOTYuMTYgNTU5Ljg0IDI4Ny45MSA1NjMuNTlDMjc5LjY1IDU2Ny4zNSAyNzIuODMgNTY5LjIyIDI2Ny40MyA1NjkuMjJDMjU2LjMyIDU2OS4yMiAyNDguODIgNTY1LjM5IDI0NC45MiA1NTcuNzRDMjQxLjAyIDU1MC4wOSAyMzkuMDcgNTM5LjUxIDIzOS4wNyA1MjZDMjM5LjA3IDUwOS41IDI0MC40MiA0OTAuNzQgMjQzLjEyIDQ2OS43M0MyNDUuODIgNDQ4LjczIDI0OC44OSA0MjYuNjcgMjUyLjM1IDQwMy41NkMyNTUuOCAzODAuNDUgMjU4Ljg4IDM1Ni45NyAyNjEuNTggMzMzLjExQzI2NC4yOCAzMDkuMjUgMjY1LjYzIDI4Ni4yMiAyNjUuNjMgMjY0LjAxQzI2NS42MyAyNDkuNiAyNjQuMDUgMjM1LjEzIDI2MC45IDIyMC41N0MyNTcuNzUgMjA2LjAyIDI1Mi42NCAxOTIuODggMjQ1LjU5IDE4MS4xOEMyMzguNTQgMTY5LjQ4IDIyOS4zMSAxNTkuOTUgMjE3LjkgMTUyLjU5QzIwNi40OSAxNDUuMjQgMTkyLjM5IDE0MS41NiAxNzUuNTggMTQxLjU2QzE0Ni4xNiAxNDEuNTYgMTE4Ljg2IDE0OS4zNyA5My42NTAxIDE2NC45N0M2OC40NDAxIDE4MC41OCA0NS40ODAxIDIwMS4yMSAyNC43NzAxIDIyNi44N0MxNi40MjAxIDIzNy4yMSA4LjQ2MDEyIDI0OC4xMyAwLjg3MDExNyAyNTkuNjJWNDQ3LjY2QzIuNjIwMTIgNDQxLjUzIDQuNDIwMTEgNDM1LjQ0IDYuMzEwMTEgNDI5LjQ1QzE2LjIzMDEgMzk4LjA4IDI3LjcxMDEgMzY5LjEyIDQwLjc3MDEgMzQyLjU2Wk01NDYuOTggNTQ2LjI2QzU1My44OCA1NDcuNDYgNTYwLjMzIDU0OS40OSA1NjYuMzQgNTUyLjM0QzU3Mi4zNCA1NTUuMiA1NzcuMzcgNTU5LjAyIDU4MS40MiA1NjMuODJDNTg1LjQ3IDU2OC42MiA1ODcuNSA1NzQuNDggNTg3LjUgNTgxLjM4QzU4Ny41IDU4NC42OSA1ODYuNDUgNTg3LjE2IDU4NC4zNSA1ODguODFDNTgyLjI1IDU5MC40NiA1NzguMzQgNTkxLjI5IDU3Mi42NSA1OTEuMjlDNTY3Ljg1IDU5MS4yOSA1NjIuOTcgNTg5LjY0IDU1OC4wMiA1ODYuMzRDNTUzLjA3IDU4My4wNCA1NDguNDkgNTc5LjA3IDU0NC4yOSA1NzQuNDFDNTQwLjA4IDU2OS43NiA1MzYuNTYgNTY0LjczIDUzMy43MSA1NTkuMzNDNTMwLjg1IDU1My45MyA1MjkuMTMgNTQ4Ljk4IDUyOC41MyA1NDQuNDdDNTMzLjkzIDU0NC40NiA1NDAuMDggNTQ1LjA2IDU0Ni45OCA1NDYuMjZaIiBmaWxsPSIjNEFCQ0U3Ii8+CjxwYXRoIGQ9Ik0xMzk0LjE3IDAuNzkwMDM5SDcyNi4xOFY3NTEuMTRIMTM5NC4xN1YwLjc5MDAzOVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xMTY3LjE1IDU2NC43MlYyNzMuMTNMMTIyOC40MyAyNzMuMTFWNTA3LjA2TDEzOTQuMTcgNTA3LjJWMC43OTAwMzlINzI2LjE4VjUwNy4yMUw4OTMuMTQgNTA3LjE2QzkyNy44MiA1MDcuMTYgOTM1LjM4IDQ5My4xNyA5MzUuNjQgNDgxLjQyQzkzNi4xNiA0NTcuMzcgOTIzLjA4IDQ0Ny43NiA4OTcuODggNDMxLjM5Qzg1MC41OSA0MDIuNTcgODQzLjUzIDM2OC45MyA4NDMuNTMgMzUxLjQ1Qzg0My41MyAyOTEuODMgODg4Ljk1IDI2NS4xNSA5MzMuOTQgMjY1LjE1Qzk1MS43NCAyNjUuMTUgOTgxLjM2IDI3Mi4xMSA5OTEuOTkgMjc2LjgyTDk4MS44IDMzMC44M0M5NzMuNyAzMjYuMzcgOTYyLjI5IDMyMS44MSA5NDcuMiAzMjEuODFDOTE5LjY2IDMyMS44MSA5MDkuODQgMzM1LjggOTA5Ljg0IDM0OC45MUM5MDkuODQgMzY5LjM0IDkxOS43NCAzNzYuMjcgOTUxLjAxIDM5NS4wOEM5ODUuNjEgNDE2LjA4IDEwMDMuMTUgNDQzLjczIDEwMDMuMTUgNDc3LjIzQzEwMDMuMTUgNTI4Ljc3IDk2NS4xNiA1NjMuNDcgOTA4LjY0IDU2My42MUw3MjYuMTcgNTYzLjY5Vjc1MS4xNEgxMzk0LjE2VjU2NC43MkgxMTY3LjE1Wk0xMTA3LjUxIDU2NC43MkgxMDUwLjAzVjI3My4zMUgxMTA3LjUxVjU2NC43MloiIGZpbGw9IiMyMTVFQUMiLz4KPHBhdGggZD0iTTEzNTAuNTQgNjg3LjhDMTM2MS44IDY4Ny44IDEzNzAuNjcgNjk2LjU1IDEzNzAuNjcgNzA3LjY4QzEzNzAuNjcgNzE5LjA2IDEzNjEuNzkgNzI3LjgxIDEzNTAuNDEgNzI3LjgxQzEzMzkuMDMgNzI3LjgxIDEzMzAuMDIgNzE5LjA2IDEzMzAuMDIgNzA3LjY4QzEzMzAuMDIgNjk2LjU1IDEzMzkuMDMgNjg3LjggMTM1MC40MSA2ODcuOEgxMzUwLjU0Wk0xMzUwLjI5IDY5MS4zQzEzNDEuMjkgNjkxLjMgMTMzNC40MSA2OTguNjggMTMzNC40MSA3MDcuNjhDMTMzNC40MSA3MTYuOTQgMTM0MS4yOSA3MjQuMTkgMTM1MC41NCA3MjQuMTlDMTM1OS41NCA3MjQuMzEgMTM2Ni4xNyA3MTYuOTQgMTM2Ni4xNyA3MDcuODFDMTM2Ni4xNyA2OTguNjggMTM1OS41NCA2OTEuMyAxMzUwLjQxIDY5MS4zSDEzNTAuMjlaTTEzNDYuNzkgNzE4LjY5SDEzNDIuOTJWNjk3LjhDMTM0NS4wNCA2OTcuNDIgMTM0Ny4zIDY5Ny4xNyAxMzUwLjE3IDY5Ny4xN0MxMzUzLjY3IDY5Ny4xNyAxMzU1LjQyIDY5Ny44IDEzNTYuOCA2OTguOEMxMzU4LjA1IDY5OS42OCAxMzU4LjkzIDcwMS4zIDEzNTguOTMgNzAzLjE4QzEzNTguOTMgNzA1LjgxIDEzNTcuMDUgNzA3LjQzIDEzNTQuOCA3MDguMThWNzA4LjQzQzEzNTYuNjggNzA5LjE4IDEzNTcuNjggNzEwLjgxIDEzNTguMyA3MTMuNDNDMTM1OC45MyA3MTYuNjggMTM1OS4zIDcxNy45MyAxMzU5LjggNzE4LjY4SDEzNTUuOEMxMzU1LjE3IDcxNy45MyAxMzU0LjY3IDcxNi4wNSAxMzU0LjE3IDcxMy4xOEMxMzUzLjY3IDcxMC45MyAxMzUyLjI5IDcwOS45MyAxMzQ5LjI5IDcwOS45M0gxMzQ2Ljc5VjcxOC42OVpNMTM0Ni43OSA3MDYuOTNIMTM0OS40MkMxMzUyLjI5IDcwNi45MyAxMzU0LjggNzA2LjA1IDEzNTQuOCA3MDMuNTVDMTM1NC44IDcwMS41NSAxMzUzLjU1IDcwMC4xNyAxMzQ5LjggNzAwLjE3QzEzNDguMyA3MDAuMTcgMTM0Ny40MiA3MDAuMjkgMTM0Ni44IDcwMC40MlY3MDYuOTNIMTM0Ni43OVoiIGZpbGw9IndoaXRlIi8+CjwvZz4KPGRlZnM+CjxjbGlwUGF0aCBpZD0iY2xpcDBfNDI3NF8xMzYiPgo8cmVjdCB3aWR0aD0iMTM5NSIgaGVpZ2h0PSI3NTIiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg=="; + +const providerLogoDataUris: Record = { + deepl: kDeepLLogoDataUri, + google: kGoogleLogoDataUri, + alpha2: kSilGlyphDataUri, +}; + +// Renders the brand mark for one translation provider, titled with its display name so hovering +// (and screen readers, via the alt/title) still reveals which service it is. +export const AiTranslationProviderLogo: React.FunctionComponent<{ + providerId: AiTranslationProviderId; + title: string; +}> = (props) => ( + {props.title} +); + +// The SIL "SIL" wordmark (with a Tai Heritage script sample), shown before the Alpha2 enable label. +export const SilAlpha2WordmarkLogo: React.FunctionComponent = () => ( + SIL +); diff --git a/src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx b/src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx index 3aed693c42eb..d1d09f416592 100644 --- a/src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx +++ b/src/BloomBrowserUI/collection/AiTranslationSettingsGroup.tsx @@ -7,7 +7,21 @@ import { ConfigrInput, } from "@sillsdev/config-r"; import { MenuItem, TextField } from "@mui/material"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import { postJsonAsync } from "../utils/bloomApi"; +import { + AiTranslationProviderLogo, + SilAlpha2WordmarkLogo, +} from "./AiTranslationProviderLogos"; + +// The order in which provider logos appear as columns in the target-language dropdown, and the +// order the engine sections appear in the settings group. Alpha2 leads (it is the SIL-hosted +// service this feature is built around), followed by the third-party engines. +const kProviderDisplayOrder: AiTranslationProviderId[] = [ + "alpha2", + "deepl", + "google", +]; export type AiTranslationProviderId = "deepl" | "google" | "alpha2"; @@ -19,6 +33,9 @@ export interface ITargetLanguageOption { export interface IAiTranslationEngineValidation { succeeded: boolean; + // True when validation failed only because the provider doesn't support the chosen target + // language; the engine is auto-skipped rather than treated as broken. + targetLanguageNotSupported: boolean; message: string; upToDate: boolean; } @@ -35,6 +52,7 @@ export interface IAiTranslationSettings { aiTranslationGooglePrivateKey?: string; aiTranslationAlpha2Enabled?: boolean; aiTranslationAlpha2ApiKey?: string; + aiTranslationAlpha2SourceLanguageTag?: string; } // Every key of IAiTranslationSettings, used to split it out of / merge it into the @@ -48,6 +66,7 @@ export const aiTranslationFlatSettingsKeys = [ "aiTranslationGooglePrivateKey", "aiTranslationAlpha2Enabled", "aiTranslationAlpha2ApiKey", + "aiTranslationAlpha2SourceLanguageTag", ] as const; export interface IAiTranslationWireEngineSettings { @@ -56,6 +75,7 @@ export interface IAiTranslationWireEngineSettings { apiKey: string; serviceAccountEmail: string; privateKey: string; + sourceLanguageTag: string; validation: IAiTranslationEngineValidation; } @@ -69,6 +89,11 @@ interface IEngineFieldSpec { providerId: AiTranslationProviderId; enabledPath: keyof IAiTranslationSettings; credentialPaths: Array; + // Non-credential settings (e.g. alpha2's source language) that still affect validation and the + // supported-language lists, so they belong in the probe/language-config keys but NOT in + // credentialPaths (changing them must not be treated as a credential change that would, say, + // leak between engines). + configPaths?: Array; } const deepLFieldSpec: IEngineFieldSpec = { @@ -90,6 +115,7 @@ const alpha2FieldSpec: IEngineFieldSpec = { providerId: "alpha2", enabledPath: "aiTranslationAlpha2Enabled", credentialPaths: ["aiTranslationAlpha2ApiKey"], + configPaths: ["aiTranslationAlpha2SourceLanguageTag"], }; // Always exactly these three engines, in this order, matching the pinned backend contract. @@ -161,6 +187,12 @@ function parseAiTranslationEngineValidation( return { succeeded: readField(candidate, "succeeded", "Succeeded") ?? false, + targetLanguageNotSupported: + readField( + candidate, + "targetLanguageNotSupported", + "TargetLanguageNotSupported", + ) ?? false, message: readField(candidate, "message", "Message") ?? "", // A freshly-run validation is by definition current for the settings that produced it. upToDate: true, @@ -179,6 +211,12 @@ function parseWireEngineValidation( return { succeeded: readField(candidate, "succeeded", "Succeeded") ?? false, + targetLanguageNotSupported: + readField( + candidate, + "targetLanguageNotSupported", + "TargetLanguageNotSupported", + ) ?? false, message: readField(candidate, "message", "Message") ?? "", upToDate: readField(candidate, "upToDate", "UpToDate") ?? false, @@ -248,6 +286,17 @@ export function flattenAiTranslationWireSettings( flatSettings[spec.credentialPaths[0]] = apiKey as never; } + if (spec.providerId === "alpha2") { + flatSettings.aiTranslationAlpha2SourceLanguageTag = + (rawEngine && + readField( + rawEngine, + "sourceLanguageTag", + "SourceLanguageTag", + )) ?? + ""; + } + const validation = parseWireEngineValidation( rawEngine && readField(rawEngine, "validation", "Validation"), ); @@ -286,7 +335,17 @@ export function buildAiTranslationWirePayload( spec.providerId === "google" ? (flat.aiTranslationGooglePrivateKey ?? "") : "", - validation: { succeeded: false, message: "", upToDate: false }, + // Only alpha2 uses a fixed source language; the other engines send it empty. + sourceLanguageTag: + spec.providerId === "alpha2" + ? (flat.aiTranslationAlpha2SourceLanguageTag ?? "") + : "", + validation: { + succeeded: false, + targetLanguageNotSupported: false, + message: "", + upToDate: false, + }, })), }; } @@ -355,31 +414,11 @@ function getEngineProbeKey( enabled: isEngineEnabled(settings, spec), targetLanguageTag: settings?.aiTranslationTargetLanguageTag ?? "", credentials: spec.credentialPaths.map((path) => settings?.[path] ?? ""), + // e.g. alpha2's source language: a change here should re-run validation (the pair changed). + config: (spec.configPaths ?? []).map((path) => settings?.[path] ?? ""), }); } -// If option is supported by only some (not all, not none) of the ready (enabled+credentialed) -// engines, returns a comma-joined display-name note (e.g. "DeepL") to show next to that option -// in the target-language dropdown. Returns "" when no note is needed. -export function getLanguageSupportNote( - option: ITargetLanguageOption, - readyProviderIds: AiTranslationProviderId[], - engineDisplayNames: Record, -): string { - const supportingReadyProviders = option.providerIds.filter((id) => - readyProviderIds.includes(id), - ); - if ( - supportingReadyProviders.length === 0 || - supportingReadyProviders.length === readyProviderIds.length - ) { - return ""; - } - return supportingReadyProviders - .map((id) => engineDisplayNames[id]) - .join(", "); -} - function getSupportedLanguagesConfigKey( settings: IAiTranslationSettings | undefined, ): string { @@ -390,6 +429,10 @@ function getSupportedLanguagesConfigKey( credentials: spec.credentialPaths.map( (path) => settings?.[path] ?? "", ), + // e.g. alpha2's source language changes which target languages it can offer. + config: (spec.configPaths ?? []).map( + (path) => settings?.[path] ?? "", + ), })), ); } @@ -495,14 +538,38 @@ function useAiTranslationEngineValidation( return { validation, isPending }; } +// Fills placeholders {0}, {1}, ... in a localized template with the given values. +function formatTemplate(template: string, ...values: string[]): string { + return values.reduce( + (text, value, index) => text.split(`{${index}}`).join(value), + template, + ); +} + function getEngineValidationDisplay( isPending: boolean, validation: IAiTranslationEngineValidation | undefined, + engineDisplayName: string, + targetLanguageLabel: string, + targetLanguageNotSupportedTemplate: string, ): { text: string; color: string } { if (isPending) { return { text: "Testing translation...", color: "#555" }; } + // The engine works, it just can't do this language, so it will be auto-skipped: an amber note, + // not a red failure. + if (validation?.targetLanguageNotSupported) { + return { + text: formatTemplate( + targetLanguageNotSupportedTemplate, + engineDisplayName, + targetLanguageLabel, + ), + color: "#b26a00", + }; + } + if (!validation?.message) { return { text: "", color: "#555" }; } @@ -543,10 +610,10 @@ const EngineValidationStatusControl: React.FunctionComponent<{ ); }; -// The live data the target-language control needs, supplied via context (below) so the control -// can live at module scope. Everything here is derived from the hook's state and refreshed each -// render; the control reads it through the context. -export interface IAiTranslationTargetLanguageControlData { +// The live data the module-scope language controls (target-language and alpha2 source-language) +// need, supplied via context (below) so the controls can live at module scope. Everything here is +// derived from the hook's state and refreshed each render; the controls read it through the context. +export interface IAiTranslationLanguageControlsData { usesEngineManagedTargetLanguages: boolean; supportedTargetLanguages: ITargetLanguageOption[]; supportedLanguagesMessage: string; @@ -555,13 +622,24 @@ export interface IAiTranslationTargetLanguageControlData { loadSupportedLanguages: () => Promise; readyProviderIds: AiTranslationProviderId[]; engineDisplayNames: Record; + noServiceSupportsLanguageNote: string; + // Shown (disabled state) when no provider is ready yet, in place of a language list. + noProviderSelectedNote: string; + // Explains that the Alpha2 language list is those it can translate from its source language; + // empty unless Alpha2 is a ready provider. + alpha2LanguagesNote: string; + // Alpha2 source-language chooser data: + alpha2SourceLanguages: ITargetLanguageOption[]; + alpha2SourceLanguagesMessage: string; + isLoadingAlpha2SourceLanguages: boolean; + alpha2SourceLanguageOptionsVersion: number; + loadAlpha2SourceLanguages: () => Promise; } -// Provided by AdvancedSettingsPanel (wrapping the Configr pane) so the module-scope -// AiTranslationTargetLanguageControl can reach the hook's live data without being redefined on -// every render. -export const AiTranslationTargetLanguageContext = React.createContext< - IAiTranslationTargetLanguageControlData | undefined +// Provided by AdvancedSettingsPanel (wrapping the Configr pane) so the module-scope language +// controls can reach the hook's live data without being redefined on every render. +export const AiTranslationLanguageControlsContext = React.createContext< + IAiTranslationLanguageControlsData | undefined >(undefined); // The target-language chooser passed to ConfigrCustomObjectInput's `control` prop. It MUST live at @@ -569,30 +647,89 @@ export const AiTranslationTargetLanguageContext = React.createContext< // a new identity on every render, which makes React unmount/remount it -- closing the dropdown and // losing focus whenever any other settings field changes. It gets its selection via the usual // value/onChange, and everything else (options, load callback, etc.) from context. +// The target-language picker is kept narrow (250px), so long language names are clipped to a +// fixed number of characters with an ellipsis; the full name and code are shown in a tooltip. +const kLanguagePickerWidthPx = 250; +const kMaxLanguageLabelChars = 16; + +function clipLanguageLabel(label: string): string { + return label.length > kMaxLanguageLabelChars + ? label.slice(0, kMaxLanguageLabelChars).trimEnd() + "…" + : label; +} + +// The tooltip shown on a language option / the chosen value: full name plus its language code. +function getLanguageTooltip(option: ITargetLanguageOption): string { + return `${option.label} (${option.value})`; +} + const AiTranslationTargetLanguageControl: React.FunctionComponent<{ value: string; disabled?: boolean; onChange: (value: string) => void; }> = (controlProps) => { - const data = React.useContext(AiTranslationTargetLanguageContext)!; + const data = React.useContext(AiTranslationLanguageControlsContext)!; if (!data.usesEngineManagedTargetLanguages) { + // No provider is ready yet, so we have no language list to offer. Rather than let the user + // type a free-form tag (which no enabled service could act on), disable the field and + // explain what to do to populate it. return ( - { - controlProps.onChange(event.target.value); - }} - inputProps={{ - "data-testid": "ai-translation-target-language-input", - }} - /> +
    + +
    + {data.noProviderSelectedNote} +
    +
    ); } + // Which ready providers actually contribute a language to the current list. We reserve a logo + // column only for these, so the columns take exactly the width the selected-and-relevant + // providers need -- not a slot for every provider that merely could exist. + const providersWithSupportedLanguages = new Set(); + data.supportedTargetLanguages.forEach((option) => + option.providerIds.forEach((id) => + providersWithSupportedLanguages.add(id), + ), + ); + + // The relevant ready providers, in a stable order, become the logo columns each language row + // lines up against (a per-provider slot, filled when that provider supports the language). Only + // shown when 2+ such providers exist -- with one there is nothing to distinguish. + const logoColumnProviderIds = kProviderDisplayOrder.filter( + (id) => + data.readyProviderIds.includes(id) && + providersWithSupportedLanguages.has(id), + ); + const showLogoColumns = logoColumnProviderIds.length >= 2; + const currentValue = controlProps.value || ""; const knownOptions = data.supportedTargetLanguages.some( (option) => option.value === currentValue, @@ -625,6 +762,11 @@ const AiTranslationTargetLanguageControl: React.FunctionComponent<{ size="small" value={currentValue} disabled={controlProps.disabled} + css={css` + max-width: ${kLanguagePickerWidthPx}px; + margin-left: auto; + display: block; + `} onChange={(event) => { controlProps.onChange(event.target.value); }} @@ -646,15 +788,68 @@ const AiTranslationTargetLanguageControl: React.FunctionComponent<{ )} {knownOptions.map((option) => { - const note = getLanguageSupportNote( - option, - data.readyProviderIds, - data.engineDisplayNames, - ); + // The synthetic current-value option (no supporting providers) is the only one + // that shows the "no enabled service supports this language" text note. + const noServiceNote = + option.providerIds.length === 0 + ? data.noServiceSupportsLanguageNote + : ""; return ( - - {option.label} - {note && ( + + + {clipLanguageLabel(option.label)} + + {showLogoColumns && ( + + {/* One fixed-width slot per ready provider so the logos line + up in columns down the list; the slot is filled only when + this language is supported by that provider. */} + {logoColumnProviderIds.map((id) => ( + + {option.providerIds.includes( + id, + ) && ( + + )} + + ))} + + )} + {noServiceNote && ( - ({note}) + ({noServiceNote}) )} @@ -688,6 +883,140 @@ const AiTranslationTargetLanguageControl: React.FunctionComponent<{ ); }; +// The alpha2 source-language chooser passed to ConfigrCustomObjectInput's `control` prop. Like +// AiTranslationTargetLanguageControl, it MUST live at module scope so React doesn't unmount/remount +// it on every render (which would close the dropdown and lose focus). It reads its options and load +// callback from the shared context and its selection via the usual value/onChange. When the endpoint +// returns no options (no target chosen yet, or alpha2 not configured) it falls back to a free-text +// field, defaulting to "en". +const AiTranslationAlpha2SourceLanguageControl: React.FunctionComponent<{ + value: string; + disabled?: boolean; + onChange: (value: string) => void; +}> = (controlProps) => { + const data = React.useContext(AiTranslationLanguageControlsContext)!; + const currentValue = controlProps.value || "en"; + + if (data.alpha2SourceLanguages.length === 0) { + return ( + { + controlProps.onChange(event.target.value); + }} + inputProps={{ + "data-testid": + "ai-translation-alpha2-source-language-input", + }} + /> + ); + } + + const knownOptions = data.alpha2SourceLanguages.some( + (option) => option.value === currentValue, + ) + ? data.alpha2SourceLanguages + : [ + ...data.alpha2SourceLanguages, + { value: currentValue, label: currentValue, providerIds: [] }, + ]; + + return ( +
    + { + controlProps.onChange(event.target.value); + }} + SelectProps={{ + onOpen: () => { + void data.loadAlpha2SourceLanguages(); + }, + }} + inputProps={{ + "data-testid": + "ai-translation-alpha2-source-language-select", + "data-language-options-version": + data.alpha2SourceLanguageOptionsVersion, + }} + > + {data.isLoadingAlpha2SourceLanguages && ( + + Loading languages... + + )} + {knownOptions.map((option) => ( + + {option.label} + + ))} + + {data.alpha2SourceLanguagesMessage && ( +
    + {data.alpha2SourceLanguagesMessage} +
    + )} + {/* Alpha2's available target languages depend on this source language, so the + explanation lives right under the source picker. */} + {data.alpha2LanguagesNote && ( +
    + + {data.alpha2LanguagesNote} +
    + )} +
    + ); +}; + export const useAiTranslationSettingsGroup = (props: { settings: IAiTranslationSettings | undefined; initialValidations?: Partial< @@ -697,15 +1026,23 @@ export const useAiTranslationSettingsGroup = (props: { targetLanguageLabel: string; deepLEnabledLabel: string; deepLApiKeyLabel: string; + deepLApiKeyDescription: string; googleEnabledLabel: string; googleServiceAccountEmailLabel: string; + googleServiceAccountDescription: string; googlePrivateKeyLabel: string; alpha2EnabledLabel: string; alpha2ApiKeyLabel: string; + alpha2SourceLanguageLabel: string; translationTestLabel: string; + targetLanguageNotSupportedTemplate: string; + noServiceSupportsLanguageNote: string; + noProviderSelectedNote: string; + // Template with a single {0} placeholder for the Alpha2 source-language name. + alpha2LanguagesNoteTemplate: string; }): { group: React.ReactElement; - targetLanguageData: IAiTranslationTargetLanguageControlData; + languageControlsData: IAiTranslationLanguageControlsData; } => { const deepLValidation = useAiTranslationEngineValidation( deepLFieldSpec, @@ -717,11 +1054,11 @@ export const useAiTranslationSettingsGroup = (props: { props.settings, props.initialValidations?.google, ); - const alpha2Validation = useAiTranslationEngineValidation( - alpha2FieldSpec, - props.settings, - props.initialValidations?.alpha2, - ); + // The live "Translation Test" is intentionally NOT run for Alpha2 (its display row is hidden + // below). Unlike DeepL/Google, an Alpha2 validation isn't a cheap single call: it has to spin + // up a whole translation system (create a text collection, kick off and poll a translation + // job, then tear it down), which is far too expensive to fire on every credential/target + // change. So we skip the alpha2 validation hook entirely for now. const [supportedTargetLanguages, setSupportedTargetLanguages] = React.useState([]); @@ -799,6 +1136,99 @@ export const useAiTranslationSettingsGroup = (props: { void loadSupportedLanguages(); }, [loadSupportedLanguages, props.settings]); + // --- Alpha2 source-language list (depends on the chosen target language) --- + const [alpha2SourceLanguages, setAlpha2SourceLanguages] = React.useState< + ITargetLanguageOption[] + >([]); + const [alpha2SourceLanguagesMessage, setAlpha2SourceLanguagesMessage] = + React.useState(""); + const [isLoadingAlpha2SourceLanguages, setIsLoadingAlpha2SourceLanguages] = + React.useState(false); + const [ + alpha2SourceLanguageOptionsVersion, + setAlpha2SourceLanguageOptionsVersion, + ] = React.useState(0); + const lastAlpha2SourceLanguagesConfigKeyRef = React.useRef(""); + + // The alpha2 source list depends on the chosen target and alpha2's own credentials. This key + // captures those inputs so we refetch only when they change. + const alpha2SourceLanguagesConfigKey = JSON.stringify({ + enabled: isEngineEnabled(props.settings, alpha2FieldSpec), + targetLanguageTag: props.settings?.aiTranslationTargetLanguageTag ?? "", + apiKey: props.settings?.aiTranslationAlpha2ApiKey ?? "", + }); + + const loadAlpha2SourceLanguages = React.useCallback(async () => { + const configKey = JSON.stringify({ + enabled: isEngineEnabled(props.settings, alpha2FieldSpec), + targetLanguageTag: + props.settings?.aiTranslationTargetLanguageTag ?? "", + apiKey: props.settings?.aiTranslationAlpha2ApiKey ?? "", + }); + if ( + !isEngineEnabled(props.settings, alpha2FieldSpec) || + !props.settings?.aiTranslationAlpha2ApiKey?.trim() || + !props.settings?.aiTranslationTargetLanguageTag?.trim() + ) { + setAlpha2SourceLanguages([]); + setAlpha2SourceLanguagesMessage(""); + lastAlpha2SourceLanguagesConfigKeyRef.current = configKey; + return; + } + + if ( + configKey === lastAlpha2SourceLanguagesConfigKeyRef.current && + alpha2SourceLanguages.length > 0 + ) { + return; + } + + setIsLoadingAlpha2SourceLanguages(true); + setAlpha2SourceLanguagesMessage(""); + try { + const response = await postJsonAsync( + "settings/aiTranslationAlpha2SourceLanguages", + props.settings, + ); + const data = response?.data as + | IAiTranslationSupportedLanguagesResponse + | undefined; + const languages = parseSupportedTargetLanguageOptions(data); + setAlpha2SourceLanguages(languages); + setAlpha2SourceLanguagesMessage(data?.message ?? ""); + lastAlpha2SourceLanguagesConfigKeyRef.current = configKey; + setAlpha2SourceLanguageOptionsVersion((value) => value + 1); + } finally { + setIsLoadingAlpha2SourceLanguages(false); + } + }, [props.settings, alpha2SourceLanguages.length]); + + // Effect justified: keeps the cached alpha2 source-language list in sync with its inputs (the + // target language and alpha2's credentials), which come from an external service. When those + // change the cached list no longer applies, so clear it; the refetch is triggered below. This + // is external-data synchronization, which warrants an Effect. + React.useEffect(() => { + if ( + alpha2SourceLanguagesConfigKey !== + lastAlpha2SourceLanguagesConfigKeyRef.current + ) { + setAlpha2SourceLanguages([]); + setAlpha2SourceLanguagesMessage(""); + } + }, [alpha2SourceLanguagesConfigKey]); + + // Fetch the alpha2 source list as soon as alpha2 is configured and a target is chosen. + React.useEffect(() => { + if ( + !isEngineEnabled(props.settings, alpha2FieldSpec) || + !props.settings?.aiTranslationAlpha2ApiKey?.trim() || + !props.settings?.aiTranslationTargetLanguageTag?.trim() + ) { + return; + } + void loadAlpha2SourceLanguages(); + }, [loadAlpha2SourceLanguages, props.settings]); + const readyProviderIds = getReadyProviderIds(props.settings); const usesEngineManagedTargetLanguages = readyProviderIds.length > 0; @@ -808,9 +1238,30 @@ export const useAiTranslationSettingsGroup = (props: { alpha2: props.alpha2EnabledLabel, }; - // Bundle the control's live inputs for the context. The control itself is a stable module-scope - // component (see AiTranslationTargetLanguageControl); this data is what changes over time. - const targetLanguageData: IAiTranslationTargetLanguageControlData = { + // The human-readable label of the currently-chosen target language, used in the per-engine + // "does not support ⟨language⟩" note; falls back to the tag if we have no matching option. + const currentTargetTag = + props.settings?.aiTranslationTargetLanguageTag ?? ""; + const currentTargetLabel = + supportedTargetLanguages.find( + (option) => option.value === currentTargetTag, + )?.label ?? currentTargetTag; + + // Alpha2's target languages are those it can translate FROM its configured source language + // (default English), so when Alpha2 is a ready provider we explain that against the list. Use + // the source language's human label when we have it, else its tag. + const alpha2SourceTag = + props.settings?.aiTranslationAlpha2SourceLanguageTag || "en"; + const alpha2SourceLabel = + alpha2SourceLanguages.find((option) => option.value === alpha2SourceTag) + ?.label ?? alpha2SourceTag; + const alpha2LanguagesNote = readyProviderIds.includes("alpha2") + ? formatTemplate(props.alpha2LanguagesNoteTemplate, alpha2SourceLabel) + : ""; + + // Bundle the controls' live inputs for the context. The controls themselves are stable + // module-scope components; this data is what changes over time. + const languageControlsData: IAiTranslationLanguageControlsData = { usesEngineManagedTargetLanguages, supportedTargetLanguages, supportedLanguagesMessage, @@ -819,112 +1270,170 @@ export const useAiTranslationSettingsGroup = (props: { loadSupportedLanguages, readyProviderIds, engineDisplayNames, + noServiceSupportsLanguageNote: props.noServiceSupportsLanguageNote, + noProviderSelectedNote: props.noProviderSelectedNote, + alpha2LanguagesNote, + alpha2SourceLanguages, + alpha2SourceLanguagesMessage, + isLoadingAlpha2SourceLanguages, + alpha2SourceLanguageOptionsVersion, + loadAlpha2SourceLanguages, }; + // Each engine's enable toggle is branded with its logo before the label. ConfigrBoolean types + // `label` as string, but config-r renders it straight into MUI's ListItemText `primary`, which + // accepts any node -- so the cast is safe here and lets us prepend the logo. + const brandedLabel = (logo: React.ReactNode, text: string) => + ( + + {logo} + {text} + + ) as unknown as string; + + const alpha2EnabledLabelWithLogo = brandedLabel( + , + props.alpha2EnabledLabel, + ); + const deepLEnabledLabelWithLogo = brandedLabel( + , + props.deepLEnabledLabel, + ); + const googleEnabledLabelWithLogo = brandedLabel( + , + props.googleEnabledLabel, + ); + + // Each engine's enable toggle and its (conditional) settings are wrapped in a single fragment + // so config-r treats the whole engine as one group child: it draws a separator line only + // BETWEEN engines, not between an engine's checkbox and its own settings. const group = ( - - {props.settings?.aiTranslationDeepLEnabled && ( - <> - - - path="aiTranslationDeepLValidationDisplay" - control={EngineValidationStatusControl} - label={props.translationTestLabel} - overrideValue={{ - ...getEngineValidationDisplay( - deepLValidation.isPending, - deepLValidation.validation, - ), - testId: "ai-translation-deepl-validation-status", - }} - /> - - )} - - {props.settings?.aiTranslationGoogleEnabled && ( - <> - - - - path="aiTranslationGoogleValidationDisplay" - control={EngineValidationStatusControl} - label={props.translationTestLabel} - overrideValue={{ - ...getEngineValidationDisplay( - googleValidation.isPending, - googleValidation.validation, - ), - testId: "ai-translation-google-validation-status", - }} - /> - - )} - - {props.settings?.aiTranslationAlpha2Enabled && ( - <> - - - path="aiTranslationAlpha2ValidationDisplay" - control={EngineValidationStatusControl} - label={props.translationTestLabel} - overrideValue={{ - ...getEngineValidationDisplay( - alpha2Validation.isPending, - alpha2Validation.validation, - ), - testId: "ai-translation-alpha2-validation-status", - }} - /> - - )} + {/* Target language is the first thing to choose, so it leads the group. */} path="aiTranslationTargetLanguageTag" control={AiTranslationTargetLanguageControl} label={props.targetLanguageLabel} /> + {/* Alpha2 (SIL's own service) leads the engine list. */} + <> + + {props.settings?.aiTranslationAlpha2Enabled && ( + <> + + + path="aiTranslationAlpha2SourceLanguageTag" + control={AiTranslationAlpha2SourceLanguageControl} + label={props.alpha2SourceLanguageLabel} + /> + {/* The "Translation Test" row is intentionally omitted for Alpha2: running + a live test means spinning up a whole Alpha2 translation system (create + a text collection, start and poll a translation job, then tear it down), + which is far too expensive to run on every settings change. Disabled and + hidden for now; the deepl/google tests remain since those are cheap. */} + + )} + + <> + + {props.settings?.aiTranslationDeepLEnabled && ( + <> + + + path="aiTranslationDeepLValidationDisplay" + control={EngineValidationStatusControl} + label={props.translationTestLabel} + overrideValue={{ + ...getEngineValidationDisplay( + deepLValidation.isPending, + deepLValidation.validation, + props.deepLEnabledLabel, + currentTargetLabel, + props.targetLanguageNotSupportedTemplate, + ), + testId: "ai-translation-deepl-validation-status", + }} + /> + + )} + + <> + + {props.settings?.aiTranslationGoogleEnabled && ( + <> + + + + path="aiTranslationGoogleValidationDisplay" + control={EngineValidationStatusControl} + label={props.translationTestLabel} + overrideValue={{ + ...getEngineValidationDisplay( + googleValidation.isPending, + googleValidation.validation, + props.googleEnabledLabel, + currentTargetLabel, + props.targetLanguageNotSupportedTemplate, + ), + testId: "ai-translation-google-validation-status", + }} + /> + + )} + ); - return { group, targetLanguageData }; + return { group, languageControlsData }; }; diff --git a/src/BloomExe/AiTranslation/AiTranslationBookScanner.cs b/src/BloomExe/AiTranslation/AiTranslationBookScanner.cs index fab45d8141f1..ce5b62432ae7 100644 --- a/src/BloomExe/AiTranslation/AiTranslationBookScanner.cs +++ b/src/BloomExe/AiTranslation/AiTranslationBookScanner.cs @@ -10,69 +10,135 @@ namespace Bloom.AiTranslation { /// - /// One eligible translation group found by AiTranslationBookScanner.Scan(): the chosen - /// source text to translate, and whether a current (up to date) translation already exists - /// per engine. + /// One eligible translation group found by AiTranslationBookScanner.Scan(): the chosen default + /// source text to translate, the text of every candidate source language in the group, and + /// whether a current (up to date) translation already exists per engine. /// public class AiTranslationGroupInfo { /// The bloom-translationGroup element this info describes. public SafeXmlElement GroupElement { get; } - /// The language tag of the bloom-editable chosen as the translation source. + /// + /// The language tag of the bloom-editable chosen (by source-language priority) as the + /// default translation source. This is what the automatic-source engines (deepl/google) use. + /// public string SourceLanguageTag { get; } - /// The trimmed text of the chosen source bloom-editable. + /// The trimmed text of the default (priority-chosen) source bloom-editable. public string SourceText { get; } + /// + /// The trimmed text of every non-AI, non-"z", non-empty bloom-editable in this group, keyed + /// by its lang attribute (in document order). Used by fixed-source engines (alpha2) to look + /// up whether the group has text in the engine's configured source language. + /// + public IReadOnlyDictionary TextsByLanguage { get; } + /// True if this is the book's data-book="bookTitle" translation group. public bool IsBookTitle { get; } /// /// Creates group info for one eligible translation group. See - /// AiTranslationBookScanner.Scan() for how the source language/text and IsBookTitle are - /// determined. + /// AiTranslationBookScanner.Scan() for how the default source language/text, the + /// per-language texts, and IsBookTitle are determined. /// public AiTranslationGroupInfo( SafeXmlElement groupElement, string sourceLanguageTag, string sourceText, + IReadOnlyDictionary textsByLanguage, bool isBookTitle ) { GroupElement = groupElement; SourceLanguageTag = sourceLanguageTag; SourceText = sourceText; + TextsByLanguage = + textsByLanguage ?? new Dictionary(StringComparer.OrdinalIgnoreCase); IsBookTitle = isBookTitle; } /// - /// The fingerprint a translation div for the given engine must currently have, based on - /// this group's current source language/text and the engine's AI language tag. + /// Resolves the source language tag and text this engine should translate from for this + /// group. Automatic-source engines (deepl/google) always use the group's priority-chosen + /// default. Fixed-source engines (alpha2) require text in their configured source language + /// (matched on normalized primary subtag, so an "en" setting matches an "en-US" editable); + /// if the group has no such text, this returns false and the group is ineligible for that + /// engine. + /// + public bool TryGetSourceForEngine( + AiTranslationEngineSettings engine, + out string sourceLanguageTag, + out string sourceText + ) + { + var fixedSource = AiTranslationBookScanner.GetFixedSourceLanguageTagOrNull(engine); + if (fixedSource == null) + { + sourceLanguageTag = SourceLanguageTag; + sourceText = SourceText; + return !string.IsNullOrWhiteSpace(SourceText); + } + + var wantedPrimarySubtag = AiTranslationBookScanner.GetPrimarySubtag(fixedSource); + // Prefer an exact-normalized match, then fall back to any editable sharing the primary + // subtag (in document order), so a fixed "en" source can use an "en-US" editable. + foreach (var pair in TextsByLanguage) + { + if ( + AiTranslationBookScanner.GetPrimarySubtag(pair.Key) == wantedPrimarySubtag + && !string.IsNullOrWhiteSpace(pair.Value) + ) + { + sourceLanguageTag = pair.Key; + sourceText = pair.Value; + return true; + } + } + + sourceLanguageTag = null; + sourceText = null; + return false; + } + + /// + /// The fingerprint a translation div for the given engine must currently have, based on the + /// engine's resolved source language/text for this group and the engine's AI language tag. + /// Returns null if the engine has no usable source for this group (a fixed-source engine + /// whose source language is absent), meaning any existing div for it is stale. /// public string GetExpectedFingerprint( AiTranslationEngineSettings engine, string targetLanguageTag ) { + if (!TryGetSourceForEngine(engine, out var sourceLanguageTag, out var sourceText)) + return null; + var aiTag = AiTranslationService.GetAiLanguageTag(targetLanguageTag, engine.ProviderId); return AiTranslationBookScanner.ComputeFingerprint( - SourceLanguageTag, - SourceText, + sourceLanguageTag, + sourceText, aiTag ); } /// - /// True if this group already has a non-empty translation div for the given engine's AI - /// language tag whose fingerprint matches the current source (i.e. it does NOT need - /// (re)translation). + /// True if this group does NOT need (re)translation for the given engine: either the engine + /// has no usable source for it (so there is nothing to translate), or it already has a + /// non-empty translation div for the engine's AI language tag whose fingerprint matches the + /// engine's current resolved source. /// public bool HasCurrentTranslation( AiTranslationEngineSettings engine, string targetLanguageTag ) { + var expectedFingerprint = GetExpectedFingerprint(engine, targetLanguageTag); + if (expectedFingerprint == null) + return true; // no source for this engine -> nothing to (re)translate. + var aiTag = AiTranslationService.GetAiLanguageTag(targetLanguageTag, engine.ProviderId); var existing = GroupElement .SafeSelectElements($"div[@lang='{aiTag}']") @@ -84,8 +150,36 @@ string targetLanguageTag if (string.IsNullOrEmpty(text)) return false; - return existing.GetAttribute("data-ai-fingerprint") - == GetExpectedFingerprint(engine, targetLanguageTag); + return existing.GetAttribute("data-ai-fingerprint") == expectedFingerprint; + } + } + + /// + /// One group's resolved translation work for a specific engine: the group plus the source + /// language/text that engine should translate from (which, for fixed-source engines, can differ + /// from the group's default source). Returned by AiTranslationBookScan.GroupsNeedingTranslation. + /// + public class AiTranslationGroupSource + { + /// The group needing translation. + public AiTranslationGroupInfo Group { get; } + + /// The language tag the engine should translate from for this group. + public string SourceLanguageTag { get; } + + /// The source text the engine should translate for this group. + public string SourceText { get; } + + /// Pairs a group with the engine-resolved source language/text. + public AiTranslationGroupSource( + AiTranslationGroupInfo group, + string sourceLanguageTag, + string sourceText + ) + { + Group = group; + SourceLanguageTag = sourceLanguageTag; + SourceText = sourceText; } } @@ -108,14 +202,45 @@ public AiTranslationBookScan(List groups, string targetL } /// - /// The ordered subset of Groups that still need a (re)translation for the given engine: - /// those lacking a current, fingerprint-matching translation div. + /// The ordered subset of Groups that still need a (re)translation for the given engine, + /// each paired with the source language/text that engine should translate from. Groups the + /// engine cannot translate (a fixed-source engine whose source language is absent from the + /// group) are excluded, as are groups that already have a current translation. /// - public List GroupsNeedingTranslation( + public List GroupsNeedingTranslation( AiTranslationEngineSettings engine ) { - return Groups.Where(g => !g.HasCurrentTranslation(engine, _targetLanguageTag)).ToList(); + var result = new List(); + foreach (var group in Groups) + { + if ( + !group.TryGetSourceForEngine( + engine, + out var sourceLanguageTag, + out var sourceText + ) + ) + continue; + if (group.HasCurrentTranslation(engine, _targetLanguageTag)) + continue; + result.Add(new AiTranslationGroupSource(group, sourceLanguageTag, sourceText)); + } + return result; + } + + /// + /// How many otherwise-eligible groups this engine must skip because it has no source text + /// for them. Only fixed-source engines (alpha2) can skip groups this way -- an + /// automatic-source engine always has the group's default source -- so this is always 0 for + /// deepl/google. Used only to leave a trace in the progress log; the book-level skip itself + /// is silent. + /// + public int CountGroupsSkippedForEngine(AiTranslationEngineSettings engine) + { + if (AiTranslationBookScanner.GetFixedSourceLanguageTagOrNull(engine) == null) + return 0; + return Groups.Count(group => !group.TryGetSourceForEngine(engine, out _, out _)); } } @@ -199,7 +324,17 @@ string translatedText _targetLanguageTag, engine.ProviderId ); - var fingerprint = ComputeFingerprint(group.SourceLanguageTag, group.SourceText, aiTag); + // Fingerprint against the engine's own resolved source (which, for a fixed-source engine + // like alpha2, may differ from the group's default source) so it matches what + // GroupsNeedingTranslation/RemoveStaleAiDivs compute for the same engine. A group is only + // ever applied for an engine that has a source for it, so this must resolve. + if (!group.TryGetSourceForEngine(engine, out var sourceLanguageTag, out var sourceText)) + { + throw new InvalidOperationException( + "ApplyTranslation called for an engine with no source for the group." + ); + } + var fingerprint = ComputeFingerprint(sourceLanguageTag, sourceText, aiTag); WriteChildDiv( group.GroupElement, @@ -389,10 +524,67 @@ private AiTranslationGroupInfo TryBuildGroupInfo(SafeXmlElement group) group, sourceDiv.GetAttribute("lang"), sourceText, + BuildTextsByLanguage(editables), isBookTitle ); } + /// + /// Builds the lang -> trimmed-text map of every candidate source editable in a group: those + /// with a lang attribute that is not an AI ("-x-ai") tag, not the "z" placeholder language, + /// and whose text is non-empty. Preserves document order; when a lang appears more than once, + /// the first non-empty occurrence wins. + /// + private static IReadOnlyDictionary BuildTextsByLanguage( + IEnumerable editables + ) + { + var textsByLanguage = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var editable in editables) + { + if (!editable.HasAttribute("lang")) + continue; + var lang = editable.GetAttribute("lang"); + if (lang.Contains(kAiLangTagFragment) || lang == kZeroLang) + continue; + var text = (editable.InnerText ?? "").Trim(); + if (string.IsNullOrWhiteSpace(text)) + continue; + if (!textsByLanguage.ContainsKey(lang)) + textsByLanguage[lang] = text; + } + + return textsByLanguage; + } + + /// + /// Returns the fixed source language tag an engine must translate from, or null if the + /// engine chooses its source automatically per group. Only the alpha2 provider is + /// fixed-source (its model selection is per source/target pair); its blank source defaults + /// to English via GetEffectiveSourceLanguageTag. + /// + internal static string GetFixedSourceLanguageTagOrNull(AiTranslationEngineSettings engine) + { + if (AiTranslationService.NormalizeProviderId(engine.ProviderId) == "alpha2") + return engine.GetEffectiveSourceLanguageTag(); + return null; + } + + /// + /// The lowercased primary (language) subtag of a Bloom language tag, used to match a fixed + /// source language against a group's editables at primary-subtag granularity (e.g. an "en" + /// setting matches an "en-US" editable). + /// + internal static string GetPrimarySubtag(string languageTag) + { + if (string.IsNullOrWhiteSpace(languageTag)) + return string.Empty; + return AiTranslationService + .NormalizeBloomLanguageTag(languageTag) + .Split('-')[0] + .ToLowerInvariant(); + } + /// /// Chooses which bloom-editable's text is the translation source: the first non-empty /// match walking sourceLanguagePriorities in order (skipping AI and "z" language divs), @@ -434,9 +626,11 @@ private static List GetAiChildDivs(SafeXmlElement parent) /// Decides whether one existing AI div under a translation group should be removed: /// always if the group is no longer eligible for AI translation at all (its AI content is /// orphaned), otherwise if its language isn't currently active (disabled engine or changed - /// target language), or its fingerprint no longer matches the group's current source text. + /// target language), or its fingerprint no longer matches the source the div's OWN engine + /// would currently translate from (which, for a fixed-source engine, is that engine's + /// configured source; a missing fixed source makes the div stale). /// - private static bool ShouldRemoveGroupAiDiv( + private bool ShouldRemoveGroupAiDiv( AiTranslationGroupInfo groupInfo, SafeXmlElement aiDiv, string lang, @@ -453,14 +647,40 @@ HashSet activeTags if (string.IsNullOrEmpty(text)) return true; - var expectedFingerprint = ComputeFingerprint( - groupInfo.SourceLanguageTag, - groupInfo.SourceText, - lang - ); + var engine = ResolveEngineFromAiLang(lang); + if (engine == null) + return true; // no matching active engine (shouldn't happen given activeTags check). + + var expectedFingerprint = groupInfo.GetExpectedFingerprint(engine, _targetLanguageTag); + if (expectedFingerprint == null) + return true; // this engine has no source for the group now -> the div is stale. + return aiDiv.GetAttribute("data-ai-fingerprint") != expectedFingerprint; } + /// + /// Finds the enabled engine that produced an AI div, by reading the provider id out of the + /// div's AI language tag (e.g. "es-x-ai-alpha2" -> the alpha2 engine). Returns null if no + /// enabled engine matches. + /// + private AiTranslationEngineSettings ResolveEngineFromAiLang(string lang) + { + if (string.IsNullOrEmpty(lang)) + return null; + var markerIndex = lang.IndexOf( + kAiLangTagFragment + "-", + StringComparison.OrdinalIgnoreCase + ); + if (markerIndex < 0) + return null; + var providerId = AiTranslationService.NormalizeProviderId( + lang.Substring(markerIndex + kAiLangTagFragment.Length + 1) + ); + return _enabledEngines.FirstOrDefault(e => + AiTranslationService.NormalizeProviderId(e.ProviderId) == providerId + ); + } + /// Finds the book's #bloomDataDiv element, or null if it isn't present. private SafeXmlElement FindBloomDataDiv() { diff --git a/src/BloomExe/AiTranslation/AiTranslationBookUpdater.cs b/src/BloomExe/AiTranslation/AiTranslationBookUpdater.cs index 21188b0887c1..82eeb57f4afc 100644 --- a/src/BloomExe/AiTranslation/AiTranslationBookUpdater.cs +++ b/src/BloomExe/AiTranslation/AiTranslationBookUpdater.cs @@ -266,25 +266,31 @@ CancellationToken ct { Engine = engine, Groups = scan.GroupsNeedingTranslation(engine), + SkippedCount = scan.CountGroupsSkippedForEngine(engine), }) .Where(w => w.Groups.Count > 0) .ToList(); var tasks = workByEngine.Select(w => - TranslateOneEngineAsync(w.Engine, w.Groups, translate, progress, ct) + TranslateOneEngineAsync(w.Engine, w.Groups, w.SkippedCount, translate, progress, ct) ); var outcomes = await Task.WhenAll(tasks); return outcomes.ToList(); } /// - /// Translates one engine's groups, grouped by source language, isolating any failure into the - /// returned outcome (cancellation is the one exception: it is rethrown so it aborts the whole - /// run rather than being recorded as this engine's failure). + /// Translates one engine's groups, grouped by the engine-resolved source language (so a + /// fixed-source engine like alpha2 translates from its configured source while deepl/google + /// translate from each group's default source), isolating any failure into the returned + /// outcome (cancellation is the one exception: it is rethrown so it aborts the whole run + /// rather than being recorded as this engine's failure). skippedCount is how many eligible + /// groups this engine had to skip for lack of source text; when non-zero it is noted in the + /// progress log. /// private static async Task TranslateOneEngineAsync( AiTranslationEngineSettings engine, - List groups, + List groups, + int skippedCount, AiTranslateSegmentsDelegate translate, IWebSocketProgress progress, CancellationToken ct @@ -311,10 +317,23 @@ CancellationToken ct var translated = await translate(engine, segments, languageGroup.Key, ct); for (var i = 0; i < groupList.Count; i++) { - outcome.Translations.Add((groupList[i], translated[i])); + outcome.Translations.Add((groupList[i].Group, translated[i])); } } + if (skippedCount > 0) + { + progress.MessageWithParams( + "EditTab.AiTranslation.EngineSkippedNoSourceText", + "{0} is the translation engine's name (e.g. SIL Alpha2), which must not be translated; {1} is a count of text boxes; {2} is a language tag (e.g. \"en\")", + "{0}: skipped {1} text box(es) with no {2} text.", + ProgressKind.Progress, + engineName, + skippedCount, + engine.GetEffectiveSourceLanguageTag() + ); + } + progress.MessageWithParams( "EditTab.AiTranslation.EngineDone", "{0} is the translation engine's name (e.g. DeepL)", diff --git a/src/BloomExe/AiTranslation/AiTranslationEngineSettings.cs b/src/BloomExe/AiTranslation/AiTranslationEngineSettings.cs index 9072e0a50320..63840e702bb5 100644 --- a/src/BloomExe/AiTranslation/AiTranslationEngineSettings.cs +++ b/src/BloomExe/AiTranslation/AiTranslationEngineSettings.cs @@ -32,6 +32,14 @@ public class AiTranslationEngineSettings /// public string PrivateKey = ""; + /// + /// The source language (Bloom/BCP-47 tag) to translate FROM. Only meaningful for the + /// alpha2 provider, whose model selection is per source/target pair; deepl/google choose + /// their source automatically per translation group and ignore this. A blank value means + /// English ("en"); use GetEffectiveSourceLanguageTag() to read it with that default applied. + /// + public string SourceLanguageTag = ""; + /// /// Fingerprint (see AiTranslationService.GetEngineFingerprint) of this engine's /// configuration and the target language at the time it was last validated. @@ -43,11 +51,28 @@ public class AiTranslationEngineSettings /// public bool LastValidationSucceeded; + /// + /// True if the last validation failed specifically because this engine's provider does + /// not support the chosen target language (as opposed to a credential/network error). When + /// set, the settings UI shows a "does not support ⟨language⟩, will be skipped" note and the + /// engine is simply excluded from translation rather than treated as misconfigured. + /// + public bool LastValidationTargetLanguageNotSupported; + /// /// The message (success text or error) from the last validation attempt. /// public string LastValidationMessage = ""; + /// + /// The effective source language tag to translate from: the configured SourceLanguageTag + /// if non-blank, otherwise English ("en"). Only the alpha2 provider consults this. + /// + public string GetEffectiveSourceLanguageTag() + { + return string.IsNullOrWhiteSpace(SourceLanguageTag) ? "en" : SourceLanguageTag.Trim(); + } + /// /// Creates a deep (independent) copy of this engine's settings. /// @@ -60,8 +85,10 @@ public AiTranslationEngineSettings Clone() ApiKey = ApiKey, ServiceAccountEmail = ServiceAccountEmail, PrivateKey = PrivateKey, + SourceLanguageTag = SourceLanguageTag, ValidatedConfigurationFingerprint = ValidatedConfigurationFingerprint, LastValidationSucceeded = LastValidationSucceeded, + LastValidationTargetLanguageNotSupported = LastValidationTargetLanguageNotSupported, LastValidationMessage = LastValidationMessage, }; } diff --git a/src/BloomExe/AiTranslation/AiTranslationService.cs b/src/BloomExe/AiTranslation/AiTranslationService.cs index ec5e87fd8817..c09ad06fe1b4 100644 --- a/src/BloomExe/AiTranslation/AiTranslationService.cs +++ b/src/BloomExe/AiTranslation/AiTranslationService.cs @@ -19,6 +19,14 @@ namespace Bloom.AiTranslation public class AiTranslationValidationResult { public bool Succeeded { get; set; } + + /// + /// True when validation failed specifically because the provider does not support the + /// chosen target language (rather than a credential/network problem). In that case the + /// engine is auto-skipped for translation rather than reported as broken. + /// + public bool TargetLanguageNotSupported { get; set; } + public string ConfigurationFingerprint { get; set; } public string Message { get; set; } } @@ -31,7 +39,6 @@ public class AiTranslationValidationResult public class AiTranslationService { public const string kValidationProbeText = "Today a reader, tomorrow a leader."; - public const string kValidationProbeSourceLanguageTag = "en"; private static readonly HttpClient _httpClient = new HttpClient(); private readonly CollectionSettings _collectionSettings; @@ -153,34 +160,117 @@ int maxRequestBytes } /// - /// Validates one engine's configuration, credentials, and target language with a probe translation. + /// Validates one engine's configuration, credentials, and target language. First, if the + /// provider can cheaply list its supported target languages and the chosen target is + /// definitively absent, it short-circuits with TargetLanguageNotSupported (the engine is + /// fine, it just can't do this language, so it will be auto-skipped rather than reported as + /// broken). Otherwise it runs a probe translation. The probe uses the engine's effective + /// source language (only alpha2 differs from the "en" default); the probe text is always + /// English and only proves that the pair/model and credentials work, not translation quality. /// public async Task ValidateEngineAsync( AiTranslationEngineSettings engine, CancellationToken ct ) { + var targetLanguageTag = _collectionSettings.AiTranslationTargetLanguageTag; + var normalizedTarget = NormalizeBloomLanguageTag(targetLanguageTag); + + var unsupportedResult = await CheckTargetLanguageSupportedAsync( + engine, + normalizedTarget, + targetLanguageTag, + ct + ); + if (unsupportedResult != null) + return unsupportedResult; + var translations = await TranslateSegmentsAsync( engine, new[] { kValidationProbeText }, - kValidationProbeSourceLanguageTag, + engine.GetEffectiveSourceLanguageTag(), ct ); return new AiTranslationValidationResult { Succeeded = true, - ConfigurationFingerprint = GetEngineFingerprint( - engine, - _collectionSettings.AiTranslationTargetLanguageTag - ), + ConfigurationFingerprint = GetEngineFingerprint(engine, targetLanguageTag), Message = translations[0], }; } + /// + /// If the provider can list its supported target languages and the chosen target is + /// definitively NOT among them, returns a failed result flagged TargetLanguageNotSupported; + /// otherwise (target is supported, list is empty, or the list couldn't be fetched) returns + /// null so the caller falls through to the normal probe. Never throws: a failure to fetch + /// the list is treated as "unknown", deferring to the probe which surfaces real errors. + /// + private async Task CheckTargetLanguageSupportedAsync( + AiTranslationEngineSettings engine, + string normalizedTarget, + string targetLanguageTag, + CancellationToken ct + ) + { + if (string.IsNullOrWhiteSpace(normalizedTarget)) + return null; + + List supportedTargets; + try + { + var provider = GetProvider(engine.ProviderId); + supportedTargets = await provider.GetSupportedTargetLanguagesAsync( + engine, + BuildAlpha2SourceLanguageTags(engine), + _httpClient, + ct + ); + } + catch (Exception e) + when (e is HttpRequestException + || e is InvalidOperationException + || e is ArgumentException + || e is CryptographicException + || e is Newtonsoft.Json.JsonException + ) + { + // Couldn't determine support; let the probe be the judge. + return null; + } + + if (supportedTargets == null || supportedTargets.Count == 0) + return null; + + var isSupported = supportedTargets.Any(o => + string.Equals( + NormalizeBloomLanguageTag(o.Value), + normalizedTarget, + StringComparison.OrdinalIgnoreCase + ) + ); + if (isSupported) + return null; + + return new AiTranslationValidationResult + { + Succeeded = false, + TargetLanguageNotSupported = true, + ConfigurationFingerprint = GetEngineFingerprint(engine, targetLanguageTag), + // This message is a plain-English fallback for persistence/logs; the settings UI + // shows its own localized "does not support ⟨language⟩, will be skipped" note driven + // by the TargetLanguageNotSupported flag. + Message = + $"{GetProviderDisplayName(engine.ProviderId)} does not support translating to '{normalizedTarget}'.", + }; + } + /// /// Gets the union of target languages supported by all ENABLED engines on the collection, /// deduped by language tag (each option records which of those engines' providers support it). + /// For source-dependent providers (alpha2) the list is looked up for that engine's own + /// configured source language only (see BuildAlpha2SourceLanguageTags). /// public async Task> GetSupportedTargetLanguagesAsync( CancellationToken ct @@ -193,11 +283,13 @@ CancellationToken ct foreach (var engine in _collectionSettings.AiTranslationEngines.Where(e => e.Enabled)) { var provider = GetProvider(engine.ProviderId); + var likelySourceLanguageTags = BuildAlpha2SourceLanguageTags(engine); List options; try { options = await provider.GetSupportedTargetLanguagesAsync( engine, + likelySourceLanguageTags, _httpClient, ct ); @@ -242,6 +334,32 @@ CancellationToken ct .ToList(); } + /// + /// Lists the source languages the given engine's provider can translate FROM into the + /// collection's target language. Only the alpha2 provider (whose source/target pairing is + /// meaningful) returns anything; other providers return an empty list. Used to populate the + /// Alpha2 source-language chooser in Collection Settings. + /// + public async Task> GetSupportedSourceLanguagesAsync( + AiTranslationEngineSettings engine, + string targetLanguageTag, + CancellationToken ct + ) + { + var provider = GetProvider(engine.ProviderId); + if (provider is Alpha2TranslationProvider alpha2) + { + return await alpha2.GetSupportedSourceLanguagesAsync( + engine, + targetLanguageTag, + _httpClient, + ct + ); + } + + return new List(); + } + /// /// Builds the Bloom language tag used for AI content in a translation group for the given engine. /// @@ -424,6 +542,10 @@ string targetLanguageTag var normalizedProvider = NormalizeProviderId(engine.ProviderId); var normalizedTargetLanguageTag = NormalizeBloomLanguageTag(targetLanguageTag); + // Only alpha2 actually uses SourceLanguageTag, but we fold it in uniformly: deepl/google + // leave it blank, so it normalizes to "" and doesn't perturb their fingerprint. Every + // engine re-validates once after this field was added, which is fine (feature unreleased). + var normalizedSourceLanguageTag = NormalizeBloomLanguageTag(engine.SourceLanguageTag); var credentialKey = normalizedProvider switch { "google" => @@ -431,10 +553,34 @@ string targetLanguageTag _ => engine.ApiKey?.Trim() ?? string.Empty, }; var fingerprintInput = - $"{normalizedProvider}\n{normalizedTargetLanguageTag}\n{credentialKey}"; + $"{normalizedProvider}\n{normalizedTargetLanguageTag}\n{normalizedSourceLanguageTag}\n{credentialKey}"; return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(fingerprintInput))); } + /// + /// Builds the deduped, normalized, blank-filtered list of source languages a + /// source-dependent provider (alpha2) should probe when listing its supported targets: + /// the engine's configured (effective) source, English, and the collection's languages. + /// + /// + /// The source language(s) to look up an engine's supported TARGET languages against. Only + /// alpha2 consumes this (its supported targets are per source language). We deliberately use + /// ONLY the engine's own configured source (defaulting to English when blank) -- NOT a union + /// with English and the collection languages. Alpha2 unions its results across every source + /// passed here, and because English can reach a near-superset of targets, including it made + /// the target list look the same no matter which source the user chose (BL-16549). Returned + /// as a single-element list to match the provider's IReadOnlyList<string> parameter. + /// + private static IReadOnlyList BuildAlpha2SourceLanguageTags( + AiTranslationEngineSettings engine + ) + { + var tag = NormalizeBloomLanguageTag(engine.GetEffectiveSourceLanguageTag()); + return string.IsNullOrWhiteSpace(tag) + ? (IReadOnlyList)Array.Empty() + : new[] { tag }; + } + private IAiTranslationProvider GetProvider(string providerId) { var normalizedProviderId = NormalizeProviderId(providerId); diff --git a/src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs b/src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs index 34c2599c6728..2f5fb929c8f8 100644 --- a/src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs +++ b/src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -30,26 +31,272 @@ internal sealed class Alpha2TranslationProvider : IAiTranslationProvider private static readonly TimeSpan kPollInterval = TimeSpan.FromSeconds(3); private static readonly TimeSpan kPollTimeout = TimeSpan.FromMinutes(5); + // The /v2/translation_languages endpoint is queried once per (apiKey, src|trg iso3) key. + // AiTranslationService is constructed fresh per API call, so this cache MUST be static to + // survive across calls; a short TTL keeps it from going stale while still collapsing the + // burst of queries a single dropdown-open produces. + private static readonly TimeSpan kLanguageCacheTtl = TimeSpan.FromMinutes(10); + private static readonly ConcurrentDictionary< + string, + (DateTime Expiry, List Languages) + > _languageCache = + new ConcurrentDictionary)>(); + + // Lazily-built reverse map from ISO 639-3 code to the shortest registered Bloom/BCP-47 + // language tag, so alpha2's "fra" can dedup with DeepL's "fr" in the service-level union + // (which keys on the option value). See MapIso6393ToBloomTag. + private static readonly Lazy> _iso6393ToBloomTag = new Lazy< + Dictionary + >(BuildIso6393ToBloomTagMap); + public string ProviderId => "alpha2"; public int MaxSegmentsPerRequest => 500; public int MaxRequestBytes => 400_000; /// - /// Alpha2 has no supported-languages matrix: /v2/translation_models only returns - /// {id, name} per model, with no src/trg metadata, so there is no way to enumerate which - /// target languages are supported without querying every language pair up front. Alpha2 - /// viability for a given pair is instead proven by the validation probe - /// (TranslateBatchAsync), which throws a clear "no translation model" error when a pair - /// isn't supported. So the settings UI's union dropdown simply won't include - /// Alpha2-only languages. + /// Lists the target languages Alpha2 can translate INTO from the given likely sources. Since + /// Alpha2's supported targets are per source language, this queries + /// /v2/translation_languages?src={iso3} once for each distinct (mappable) likely source and + /// unions the results, deduped by the reverse-mapped Bloom tag. Sources that can't be mapped + /// to ISO 639-3 are skipped; a query that fails for one source doesn't fail the whole list + /// (unless every source query fails); an empty result for a source is not an error. /// - public Task> GetSupportedTargetLanguagesAsync( + public async Task> GetSupportedTargetLanguagesAsync( AiTranslationEngineSettings engine, + IReadOnlyList likelySourceLanguageTags, HttpClient httpClient, CancellationToken ct ) { - return Task.FromResult(new List()); + EnsureCredentials(engine); + return await GetLanguagesForAxisAsync( + httpClient, + engine.ApiKey.Trim(), + "src", + likelySourceLanguageTags, + ct + ); + } + + /// + /// Lists the source languages Alpha2 can translate FROM into the given target language, via + /// /v2/translation_languages?trg={iso3}. Used by the Alpha2 source-language chooser in + /// Collection Settings. Returns an empty list if the target can't be mapped to ISO 639-3. + /// + public async Task> GetSupportedSourceLanguagesAsync( + AiTranslationEngineSettings engine, + string targetLanguageTag, + HttpClient httpClient, + CancellationToken ct + ) + { + EnsureCredentials(engine); + return await GetLanguagesForAxisAsync( + httpClient, + engine.ApiKey.Trim(), + "trg", + new[] { targetLanguageTag }, + ct + ); + } + + /// + /// Shared implementation for both directions: maps each pivot language tag to ISO 639-3 + /// (skipping unmappable ones), queries /v2/translation_languages with the given axis + /// parameter ("src" or "trg") once per distinct pivot, and unions the results deduped by + /// reverse-mapped Bloom tag. Throws only if every pivot query failed. + /// + private static async Task> GetLanguagesForAxisAsync( + HttpClient httpClient, + string apiKey, + string axisParam, + IReadOnlyList pivotLanguageTags, + CancellationToken ct + ) + { + var pivotIso3Codes = new List(); + foreach (var pivotTag in pivotLanguageTags ?? Array.Empty()) + { + try + { + var iso3 = MapToIso6393(pivotTag); + if (!pivotIso3Codes.Contains(iso3)) + pivotIso3Codes.Add(iso3); + } + catch (InvalidOperationException) + { + // An unmappable pivot language (e.g. a collection language with no ISO 639-3 + // code) must not break the whole list; just skip it. + } + } + + var optionsByTag = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + var failures = new List(); + var anySucceeded = false; + foreach (var iso3 in pivotIso3Codes) + { + try + { + var languages = await GetLanguagesForKeyAsync( + httpClient, + apiKey, + axisParam, + iso3, + ct + ); + anySucceeded = true; + foreach (var option in languages) + { + if (!optionsByTag.ContainsKey(option.Value)) + optionsByTag[option.Value] = option; + } + } + catch (Exception e) + when (e is HttpRequestException || e is InvalidOperationException) + { + failures.Add(e.Message); + } + } + + // If we had pivots to query but every one failed, surface the error; otherwise return + // whatever union we managed to build (possibly empty, which is a valid answer). + if (!anySucceeded && failures.Count > 0) + throw new InvalidOperationException(string.Join(" ", failures)); + + return optionsByTag + .Values.OrderBy(o => o.Label, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + } + + /// + /// Fetches (and caches for kLanguageCacheTtl) the languages Alpha2 reports for one + /// axis/iso3 key. Only successful responses are cached. + /// + private static async Task> GetLanguagesForKeyAsync( + HttpClient httpClient, + string apiKey, + string axisParam, + string iso3, + CancellationToken ct + ) + { + var cacheKey = $"{apiKey}\n{axisParam}:{iso3}"; + if ( + _languageCache.TryGetValue(cacheKey, out var cached) + && cached.Expiry > DateTime.UtcNow + ) + { + return cached.Languages; + } + + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"{kApiBaseUrl}/v2/translation_languages?{axisParam}={Uri.EscapeDataString(iso3)}" + ); + request.Headers.Add("api_key", apiKey); + + using var response = await httpClient.SendAsync(request, ct); + var responseContent = await response.Content.ReadAsStringAsync(); + AiTranslationProviderHelpers.EnsureSuccess(response, responseContent, "Alpha2"); + + var languages = ParseTranslationLanguages(responseContent); + _languageCache[cacheKey] = (DateTime.UtcNow + kLanguageCacheTtl, languages); + return languages; + } + + /// + /// Parses a /v2/translation_languages response body -- a JSON array of + /// {iso, name, display, models:[...]} objects -- into target-language options, mapping each + /// entry's ISO 639-3 code back to the shortest registered Bloom tag. Kept internal and + /// HTTP-free so tests can exercise the parsing/reverse-mapping without mocking the network. + /// + internal static List ParseTranslationLanguages( + string responseContent + ) + { + var array = JArray.Parse(responseContent); + var options = new List(); + var seenTags = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in array) + { + var iso = entry["iso"]?.Value(); + if (string.IsNullOrWhiteSpace(iso)) + continue; + + var bloomTag = MapIso6393ToBloomTag(iso); + if (!seenTags.Add(bloomTag)) + continue; + + var name = entry["name"]?.Value(); + options.Add( + new AiTranslationTargetLanguageOption + { + Value = bloomTag, + Label = string.IsNullOrWhiteSpace(name) ? bloomTag : name, + ProviderIds = new List { "alpha2" }, + } + ); + } + + options.Sort( + (first, second) => + StringComparer.CurrentCultureIgnoreCase.Compare(first.Label, second.Label) + ); + return options; + } + + /// + /// Reverse of MapToIso6393: maps an ISO 639-3 code (as Alpha2 returns) to the shortest + /// registered Bloom/BCP-47 language tag (e.g. "fra" -> "fr"), so an Alpha2 language dedups + /// with the same language from DeepL/Google in the settings union. An iso3 with no shorter + /// registered tag (the usual case for languages that only have a 3-letter code) passes + /// through unchanged. + /// + internal static string MapIso6393ToBloomTag(string iso3) + { + if (string.IsNullOrWhiteSpace(iso3)) + return iso3; + + var normalized = iso3.Trim().ToLowerInvariant(); + if (_iso6393ToBloomTag.Value.TryGetValue(normalized, out var bloomTag)) + return bloomTag; + + return normalized; + } + + /// + /// Builds the ISO-639-3 -> shortest-registered-Bloom-tag map once. When several registered + /// tags share an ISO 639-3 code, the shortest wins (ties broken lexicographically) so we + /// prefer the familiar 2-letter tag (e.g. "fr" over "fra"). + /// + private static Dictionary BuildIso6393ToBloomTagMap() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var language in StandardSubtags.RegisteredLanguages) + { + var iso3 = language.Iso3Code; + var code = language.Code; + if (string.IsNullOrWhiteSpace(iso3) || string.IsNullOrWhiteSpace(code)) + continue; + + var key = iso3.ToLowerInvariant(); + var candidate = code.ToLowerInvariant(); + if ( + !map.TryGetValue(key, out var existing) + || candidate.Length < existing.Length + || ( + candidate.Length == existing.Length + && string.CompareOrdinal(candidate, existing) < 0 + ) + ) + { + map[key] = candidate; + } + } + + return map; } /// diff --git a/src/BloomExe/AiTranslation/DeepLTranslationProvider.cs b/src/BloomExe/AiTranslation/DeepLTranslationProvider.cs index 51c120dbd478..27f7ffa92add 100644 --- a/src/BloomExe/AiTranslation/DeepLTranslationProvider.cs +++ b/src/BloomExe/AiTranslation/DeepLTranslationProvider.cs @@ -23,10 +23,12 @@ internal sealed class DeepLTranslationProvider : IAiTranslationProvider public async Task> GetSupportedTargetLanguagesAsync( AiTranslationEngineSettings engine, + IReadOnlyList likelySourceLanguageTags, HttpClient httpClient, CancellationToken ct ) { + // DeepL's target list is source-independent, so likelySourceLanguageTags is ignored. EnsureCredentials(engine); using var request = new HttpRequestMessage( diff --git a/src/BloomExe/AiTranslation/GoogleTranslationProvider.cs b/src/BloomExe/AiTranslation/GoogleTranslationProvider.cs index 1779192972c0..8ee24628dcf0 100644 --- a/src/BloomExe/AiTranslation/GoogleTranslationProvider.cs +++ b/src/BloomExe/AiTranslation/GoogleTranslationProvider.cs @@ -33,10 +33,12 @@ internal sealed class GoogleTranslationProvider : IAiTranslationProvider public async Task> GetSupportedTargetLanguagesAsync( AiTranslationEngineSettings engine, + IReadOnlyList likelySourceLanguageTags, HttpClient httpClient, CancellationToken ct ) { + // Google's target list is source-independent, so likelySourceLanguageTags is ignored. EnsureCredentials(engine); var accessToken = await GetAccessTokenAsync(engine, httpClient, ct); diff --git a/src/BloomExe/AiTranslation/IAiTranslationProvider.cs b/src/BloomExe/AiTranslation/IAiTranslationProvider.cs index f175dea4e9e3..7d8b15bca42e 100644 --- a/src/BloomExe/AiTranslation/IAiTranslationProvider.cs +++ b/src/BloomExe/AiTranslation/IAiTranslationProvider.cs @@ -43,8 +43,16 @@ internal interface IAiTranslationProvider /// int MaxRequestBytes { get; } + /// + /// Lists the target languages this provider supports for the given engine. + /// likelySourceLanguageTags is a hint used only by providers (alpha2) whose supported-target + /// set depends on the source language: the caller passes the sources it is likely to + /// translate from (the engine's configured source, "en", and the collection's languages). + /// Providers that don't need it (deepl/google) ignore the hint. + /// Task> GetSupportedTargetLanguagesAsync( AiTranslationEngineSettings engine, + IReadOnlyList likelySourceLanguageTags, HttpClient httpClient, CancellationToken ct ); diff --git a/src/BloomExe/Collection/CollectionSettings.cs b/src/BloomExe/Collection/CollectionSettings.cs index 86c315f98ce3..bc8181af2b69 100644 --- a/src/BloomExe/Collection/CollectionSettings.cs +++ b/src/BloomExe/Collection/CollectionSettings.cs @@ -450,6 +450,12 @@ public void Save() engineElement.Add( new XAttribute("validationSucceeded", engine.LastValidationSucceeded) ); + engineElement.Add( + new XAttribute( + "validationTargetLanguageNotSupported", + engine.LastValidationTargetLanguageNotSupported + ) + ); engineElement.Add( new XAttribute( "validatedFingerprint", @@ -461,6 +467,9 @@ public void Save() new XElement("ServiceAccountEmail", engine.ServiceAccountEmail ?? "") ); engineElement.Add(new XElement("PrivateKey", engine.PrivateKey ?? "")); + engineElement.Add( + new XElement("SourceLanguageTag", engine.SourceLanguageTag ?? "") + ); engineElement.Add( new XElement("ValidationMessage", engine.LastValidationMessage ?? "") ); @@ -755,17 +764,25 @@ public void Load() engineElement.Attribute("validationSucceeded")?.Value, out var validationSucceeded ); + bool.TryParse( + engineElement.Attribute("validationTargetLanguageNotSupported")?.Value, + out var validationTargetLanguageNotSupported + ); return new AiTranslationEngineSettings { ProviderId = engineElement.Attribute("id")?.Value ?? "", Enabled = enabled, LastValidationSucceeded = validationSucceeded, + LastValidationTargetLanguageNotSupported = + validationTargetLanguageNotSupported, ValidatedConfigurationFingerprint = engineElement.Attribute("validatedFingerprint")?.Value ?? "", ApiKey = engineElement.Element("ApiKey")?.Value ?? "", ServiceAccountEmail = engineElement.Element("ServiceAccountEmail")?.Value ?? "", PrivateKey = engineElement.Element("PrivateKey")?.Value ?? "", + SourceLanguageTag = + engineElement.Element("SourceLanguageTag")?.Value ?? "", LastValidationMessage = engineElement.Element("ValidationMessage")?.Value ?? "", }; diff --git a/src/BloomExe/web/controllers/CollectionSettingsApi.cs b/src/BloomExe/web/controllers/CollectionSettingsApi.cs index a6e1b7363193..960417a24f7f 100644 --- a/src/BloomExe/web/controllers/CollectionSettingsApi.cs +++ b/src/BloomExe/web/controllers/CollectionSettingsApi.cs @@ -109,6 +109,12 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) false, true ); + apiHandler.RegisterAsyncEndpointHandler( + kApiUrlPart + "aiTranslationAlpha2SourceLanguages", + HandleGetAiTranslationAlpha2SourceLanguagesAsync, + false, + true + ); apiHandler.RegisterBooleanEndpointHandler( kApiUrlPart + "lockedToOneDownloadedBook", request => _collectionSettings.EditingABlorgBook, @@ -385,9 +391,11 @@ private object GetAiTranslationData(CollectionSettingsDialog dialog) apiKey = engine.ApiKey ?? "", serviceAccountEmail = engine.ServiceAccountEmail ?? "", privateKey = engine.PrivateKey ?? "", + sourceLanguageTag = engine.SourceLanguageTag ?? "", validation = new { succeeded = engine.LastValidationSucceeded, + targetLanguageNotSupported = engine.LastValidationTargetLanguageNotSupported, message = engine.LastValidationMessage ?? "", upToDate = String.Equals( engine.ValidatedConfigurationFingerprint, @@ -408,6 +416,7 @@ private static void InvalidateEngineValidation(AiTranslationEngineSettings engin { engine.ValidatedConfigurationFingerprint = String.Empty; engine.LastValidationSucceeded = false; + engine.LastValidationTargetLanguageNotSupported = false; engine.LastValidationMessage = String.Empty; } @@ -441,6 +450,7 @@ private async Task HandleValidateAiTranslationEngineAsync(ApiRequest request) var engine = dialog.PendingAiTranslationEngines.Single(e => e.ProviderId == providerId); var succeeded = false; + var targetLanguageNotSupported = false; var message = String.Empty; var tempSettings = new CollectionSettings { @@ -454,6 +464,7 @@ private async Task HandleValidateAiTranslationEngineAsync(ApiRequest request) tempSettings ).ValidateEngineAsync(engine, CancellationToken.None); succeeded = validationResult.Succeeded; + targetLanguageNotSupported = validationResult.TargetLanguageNotSupported; message = validationResult.Message; engine.ValidatedConfigurationFingerprint = validationResult.ConfigurationFingerprint; @@ -480,13 +491,25 @@ private async Task HandleValidateAiTranslationEngineAsync(ApiRequest request) } engine.LastValidationSucceeded = succeeded; + engine.LastValidationTargetLanguageNotSupported = targetLanguageNotSupported; engine.LastValidationMessage = message; - if (!succeeded) + // A genuine failure clears the fingerprint so we re-probe next time. The + // "target not supported" outcome, though, is a settled fact for this exact + // configuration: keep the fingerprint (already set from the result above) so the UI + // shows the persisted "will be skipped" note without re-probing on every reopen. + if (!succeeded && !targetLanguageNotSupported) { engine.ValidatedConfigurationFingerprint = String.Empty; } - request.ReplyWithJson(new { succeeded, message }); + request.ReplyWithJson( + new + { + succeeded, + targetLanguageNotSupported, + message, + } + ); } /// @@ -562,6 +585,80 @@ private async Task HandleGetAiTranslationSupportedLanguagesAsync(ApiRequest requ } } + /// + /// Gets the (pending, if Collection Settings is open) source languages the Alpha2 engine can + /// translate FROM into the currently-chosen target language, for the Alpha2 source-language + /// chooser. Replies with an empty list when no target is set or Alpha2 has no API key. + /// + private async Task HandleGetAiTranslationAlpha2SourceLanguagesAsync(ApiRequest request) + { + if (request.HttpMethod != HttpMethods.Post) + { + request.Failed(HttpStatusCode.MethodNotAllowed, "Only POST is supported."); + return; + } + + var dialog = DialogBeingEdited; + if (dialog == null) + _collectionSettings.EnsureAiTranslationEngines(); + var targetLanguageTag = + dialog?.PendingAiTranslationTargetLanguageTag + ?? _collectionSettings.AiTranslationTargetLanguageTag; + var engines = + dialog?.PendingAiTranslationEngines ?? _collectionSettings.AiTranslationEngines; + var alpha2Engine = engines.FirstOrDefault(e => + AiTranslationService.NormalizeProviderId(e.ProviderId) == "alpha2" + ); + + if ( + string.IsNullOrWhiteSpace(targetLanguageTag) + || alpha2Engine == null + || string.IsNullOrWhiteSpace(alpha2Engine.ApiKey) + ) + { + request.ReplyWithJson( + new { languages = Array.Empty(), message = String.Empty } + ); + return; + } + + var tempSettings = new CollectionSettings + { + Subscription = _collectionSettings.Subscription, + AiTranslationTargetLanguageTag = targetLanguageTag, + }; + + try + { + var options = await new AiTranslationService( + tempSettings + ).GetSupportedSourceLanguagesAsync( + alpha2Engine.Clone(), + targetLanguageTag, + CancellationToken.None + ); + var languages = options.Select(option => new + { + tag = option.Value, + name = option.Label, + providerIds = option.ProviderIds, + }); + request.ReplyWithJson(new { languages, message = String.Empty }); + } + catch (Exception e) + when (e is ArgumentException + || e is InvalidOperationException + || e is HttpRequestException + || e is CryptographicException + || e is JsonException + ) + { + request.ReplyWithJson( + new { languages = Array.Empty(), message = e.Message } + ); + } + } + private void StoreAdvancedSettingsData(JObject data, CollectionSettingsDialog dialog) { var aiTranslationConfigurationChanged = false; @@ -641,6 +738,9 @@ private void StoreAdvancedSettingsData(JObject data, CollectionSettingsDialog di ?? engine.ServiceAccountEmail; var privateKey = engineToken["privateKey"]?.Value() ?? engine.PrivateKey; + var sourceLanguageTag = + engineToken["sourceLanguageTag"]?.Value() + ?? engine.SourceLanguageTag; var engineChanged = enabled != engine.Enabled @@ -654,12 +754,18 @@ private void StoreAdvancedSettingsData(JObject data, CollectionSettingsDialog di privateKey, engine.PrivateKey, StringComparison.Ordinal + ) + || !String.Equals( + sourceLanguageTag, + engine.SourceLanguageTag, + StringComparison.Ordinal ); engine.Enabled = enabled; engine.ApiKey = apiKey; engine.ServiceAccountEmail = serviceAccountEmail; engine.PrivateKey = privateKey; + engine.SourceLanguageTag = sourceLanguageTag; if (engineChanged) { diff --git a/src/BloomTests/AiTranslation/AiTranslationBookScannerTests.cs b/src/BloomTests/AiTranslation/AiTranslationBookScannerTests.cs index 0bd3b20d84e7..c68879a44fda 100644 --- a/src/BloomTests/AiTranslation/AiTranslationBookScannerTests.cs +++ b/src/BloomTests/AiTranslation/AiTranslationBookScannerTests.cs @@ -556,5 +556,160 @@ public void RemoveAllAiDivs_RemovesEveryAiDivIncludingCurrentOnes_KeepsNonAiDivs "both English source divs should be left in place" ); } + + [Test] + public void GroupsNeedingTranslation_FixedSourceEngine_UsesConfiguredSourceLanguageText() + { + var alpha2 = new AiTranslationEngineSettings + { + ProviderId = "alpha2", + Enabled = true, + SourceLanguageTag = "fr", + }; + var dom = MakeBookDom( + @" +
    +
    +
    Hello
    +
    Bonjour
    +
    +
    " + ); + var scanner = new AiTranslationBookScanner( + dom, + "es", + new[] { alpha2 }, + new[] { "en" } // priority prefers en, but alpha2's fixed source must win for alpha2. + ); + + var needing = scanner.Scan().GroupsNeedingTranslation(alpha2); + + Assert.That(needing.Count, Is.EqualTo(1)); + Assert.That( + needing[0].SourceLanguageTag, + Is.EqualTo("fr"), + "a fixed-source engine translates from its configured source, not the priority default" + ); + Assert.That(needing[0].SourceText, Is.EqualTo("Bonjour")); + } + + [Test] + public void GroupsNeedingTranslation_FixedSourceEngine_MissingSourceLanguage_ExcludedAndCountedAsSkipped() + { + var alpha2 = new AiTranslationEngineSettings + { + ProviderId = "alpha2", + Enabled = true, + SourceLanguageTag = "fr", + }; + var dom = MakeBookDom( + @" +
    +
    +
    Hello
    +
    +
    " + ); + var scanner = new AiTranslationBookScanner(dom, "es", new[] { alpha2 }, new[] { "en" }); + var scan = scanner.Scan(); + Assert.That(scan.Groups.Count, Is.EqualTo(1), "sanity check: the group is eligible"); + + Assert.That( + scan.GroupsNeedingTranslation(alpha2), + Is.Empty, + "the group has no French text, so the fixed-source alpha2 engine must skip it" + ); + Assert.That(scan.CountGroupsSkippedForEngine(alpha2), Is.EqualTo(1)); + } + + [Test] + public void GroupsNeedingTranslation_SameGroup_Alpha2AndDeeplGetDifferentSources() + { + var deepl = new AiTranslationEngineSettings { ProviderId = "deepl", Enabled = true }; + var alpha2 = new AiTranslationEngineSettings + { + ProviderId = "alpha2", + Enabled = true, + SourceLanguageTag = "fr", + }; + var dom = MakeBookDom( + @" +
    +
    +
    Hello
    +
    Bonjour
    +
    +
    " + ); + var scanner = new AiTranslationBookScanner( + dom, + "es", + new[] { deepl, alpha2 }, + new[] { "en" } + ); + var scan = scanner.Scan(); + + var deeplWork = scan.GroupsNeedingTranslation(deepl).Single(); + var alpha2Work = scan.GroupsNeedingTranslation(alpha2).Single(); + + Assert.That( + deeplWork.SourceLanguageTag, + Is.EqualTo("en"), + "deepl uses the priority-chosen default source" + ); + Assert.That(deeplWork.SourceText, Is.EqualTo("Hello")); + Assert.That( + alpha2Work.SourceLanguageTag, + Is.EqualTo("fr"), + "alpha2 uses its fixed configured source for the same group" + ); + Assert.That(alpha2Work.SourceText, Is.EqualTo("Bonjour")); + } + + [Test] + public void RemoveStaleAiDivs_FixedSourceEngineLostItsSourceText_RemovesDiv() + { + var alpha2 = new AiTranslationEngineSettings + { + ProviderId = "alpha2", + Enabled = true, + SourceLanguageTag = "fr", + }; + const string targetTag = "es"; + var alpha2AiTag = AiTranslationService.GetAiLanguageTag(targetTag, "alpha2"); + // The group has English text but NO French text, yet carries an alpha2 (French-source) + // AI div left over from when French text existed. Since alpha2 can no longer resolve a + // French source for the group, its div is stale and must be removed. + var dom = MakeBookDom( + $@" +
    +
    +
    Hello
    +
    Hola (from French, now orphaned)
    +
    +
    " + ); + Assert.That( + dom.RawDom.SafeSelectNodes("//div[@lang and contains(@lang,'-x-ai')]").Length, + Is.EqualTo(1), + "sanity check: fixture starts with one alpha2 AI div" + ); + + var scanner = new AiTranslationBookScanner( + dom, + targetTag, + new[] { alpha2 }, + new[] { "en" } + ); + + var removedCount = scanner.RemoveStaleAiDivs(); + + Assert.That(removedCount, Is.EqualTo(1)); + Assert.That( + dom.RawDom.SafeSelectNodes("//div[@lang and contains(@lang,'-x-ai')]").Length, + Is.EqualTo(0), + "the fixed-source engine's div is stale because its source language is gone" + ); + } } } diff --git a/src/BloomTests/AiTranslation/AiTranslationBookUpdaterTests.cs b/src/BloomTests/AiTranslation/AiTranslationBookUpdaterTests.cs index d099dc5f3558..1668fa7a4874 100644 --- a/src/BloomTests/AiTranslation/AiTranslationBookUpdaterTests.cs +++ b/src/BloomTests/AiTranslation/AiTranslationBookUpdaterTests.cs @@ -423,5 +423,119 @@ public void CountStaleAiDivsWithoutMutating_ReportsCountWithoutTouchingTheRealDo "CountStaleAiDivsWithoutMutating must not mutate the real book DOM" ); } + + [Test] + public async Task RunEnginesAsync_Alpha2FixedSource_DeeplPrioritySource_SameGroup() + { + var deepl = MakeEngine("deepl"); + var alpha2 = new AiTranslationEngineSettings + { + ProviderId = "alpha2", + Enabled = true, + SourceLanguageTag = "fr", + }; + var dom = MakeBookDom( + @" +
    +
    +
    Hello
    +
    Bonjour
    +
    +
    " + ); + var scanner = new AiTranslationBookScanner( + dom, + "es", + new[] { deepl, alpha2 }, + new[] { "en" } + ); + var scan = scanner.Scan(); + + var calls = new List<(string Engine, string Lang, string[] Segments)>(); + Task translate( + AiTranslationEngineSettings e, + string[] segments, + string sourceLang, + CancellationToken ct + ) + { + lock (calls) + calls.Add((e.ProviderId, sourceLang, segments)); + return Task.FromResult(segments.Select(s => $"{e.ProviderId}:{s}").ToArray()); + } + + await AiTranslationBookUpdater.RunEnginesAsync( + scan, + new[] { deepl, alpha2 }, + translate, + new ProgressSpy(), + CancellationToken.None + ); + + var deeplCall = calls.Single(c => c.Engine == "deepl"); + var alpha2Call = calls.Single(c => c.Engine == "alpha2"); + Assert.That(deeplCall.Lang, Is.EqualTo("en"), "deepl uses the priority source"); + Assert.That(deeplCall.Segments, Is.EqualTo(new[] { "Hello" })); + Assert.That( + alpha2Call.Lang, + Is.EqualTo("fr"), + "alpha2 uses its fixed configured source for the same group" + ); + Assert.That(alpha2Call.Segments, Is.EqualTo(new[] { "Bonjour" })); + } + + [Test] + public async Task RunEnginesAsync_Alpha2_SkipsGroupWithNoSourceText_NoTranslateCallForIt() + { + var alpha2 = new AiTranslationEngineSettings + { + ProviderId = "alpha2", + Enabled = true, + SourceLanguageTag = "fr", + }; + var dom = MakeBookDom( + @" +
    +
    +
    Hello
    +
    Bonjour
    +
    +
    +
    World
    +
    +
    " + ); + var scanner = new AiTranslationBookScanner(dom, "es", new[] { alpha2 }, new[] { "en" }); + var scan = scanner.Scan(); + Assert.That(scan.Groups.Count, Is.EqualTo(2), "sanity check on fixture"); + + var translatedSegments = new List(); + Task translate( + AiTranslationEngineSettings e, + string[] segments, + string sourceLang, + CancellationToken ct + ) + { + lock (translatedSegments) + translatedSegments.AddRange(segments); + return Task.FromResult(segments.Select(s => $"ES:{s}").ToArray()); + } + + var outcomes = await AiTranslationBookUpdater.RunEnginesAsync( + scan, + new[] { alpha2 }, + translate, + new ProgressSpy(), + CancellationToken.None + ); + + Assert.That( + translatedSegments, + Is.EqualTo(new[] { "Bonjour" }), + "only the group with French text is translated; the English-only group is skipped" + ); + Assert.That(outcomes.Single().Translations.Count, Is.EqualTo(1)); + } } } diff --git a/src/BloomTests/AiTranslation/AiTranslationServiceTests.cs b/src/BloomTests/AiTranslation/AiTranslationServiceTests.cs index 8d349a4f67e5..6fdafb677cc4 100644 --- a/src/BloomTests/AiTranslation/AiTranslationServiceTests.cs +++ b/src/BloomTests/AiTranslation/AiTranslationServiceTests.cs @@ -330,6 +330,145 @@ await service.GetSupportedTargetLanguagesAsync(CancellationToken.None) Assert.That(exception.Message, Does.Contain("languages:read")); } + [Test] + public void GetEngineFingerprint_ChangesWhenSourceLanguageTagChanges() + { + var engine = new AiTranslationEngineSettings + { + ProviderId = "alpha2", + ApiKey = "same-key", + SourceLanguageTag = "en", + }; + + var englishSource = AiTranslationService.GetEngineFingerprint(engine, "fr"); + engine.SourceLanguageTag = "es"; + var spanishSource = AiTranslationService.GetEngineFingerprint(engine, "fr"); + + Assert.That(spanishSource, Is.Not.EqualTo(englishSource)); + } + + [Test] + public async Task GetSupportedTargetLanguagesAsync_LooksUpAlpha2TargetsForItsConfiguredSourceOnly() + { + var provider = new FakeLanguageListProvider( + "alpha2", + languages: new[] { ("es", "Spanish") } + ); + var collectionSettings = new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), + AiTranslationTargetLanguageTag = "de", + Language1Tag = "fr", + AiTranslationEngines = new List + { + new AiTranslationEngineSettings + { + ProviderId = "alpha2", + Enabled = true, + SourceLanguageTag = "es", + }, + }, + }; + var service = new AiTranslationService( + collectionSettings, + new Dictionary { { "alpha2", provider } } + ); + + await service.GetSupportedTargetLanguagesAsync(CancellationToken.None); + + // Only the engine's configured source is used. We must NOT union in English or the + // collection languages: alpha2 unions its target results across every source given, and + // English reaches a near-superset of targets, so including it made the list look the + // same regardless of the chosen source (BL-16549). + Assert.That(provider.LastLikelySourceLanguageTags, Is.EquivalentTo(new[] { "es" })); + } + + [Test] + public async Task ValidateEngineAsync_TargetMissingFromNonEmptyList_ReturnsTargetLanguageNotSupported_WithoutProbing() + { + var provider = new FakeValidatingProvider( + "alpha2", + supportedTargets: new[] { "es", "fr" } + ); + var collectionSettings = new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), + AiTranslationTargetLanguageTag = "de", // not in the provider's supported list + }; + var service = new AiTranslationService( + collectionSettings, + new Dictionary { { "alpha2", provider } } + ); + var engine = new AiTranslationEngineSettings { ProviderId = "alpha2", ApiKey = "key" }; + + var result = await service.ValidateEngineAsync(engine, CancellationToken.None); + + Assert.That(result.Succeeded, Is.False); + Assert.That(result.TargetLanguageNotSupported, Is.True); + Assert.That( + provider.TranslateCallCount, + Is.EqualTo(0), + "an unsupported target must short-circuit before the probe translation" + ); + } + + [Test] + public async Task ValidateEngineAsync_TargetSupported_ProbesUsingConfiguredSourceLanguage() + { + var provider = new FakeValidatingProvider("alpha2", supportedTargets: new[] { "es" }); + var collectionSettings = new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), + AiTranslationTargetLanguageTag = "es", + }; + var service = new AiTranslationService( + collectionSettings, + new Dictionary { { "alpha2", provider } } + ); + var engine = new AiTranslationEngineSettings + { + ProviderId = "alpha2", + ApiKey = "key", + SourceLanguageTag = "fr", + }; + + var result = await service.ValidateEngineAsync(engine, CancellationToken.None); + + Assert.That(result.Succeeded, Is.True); + Assert.That(result.TargetLanguageNotSupported, Is.False); + Assert.That( + provider.LastTranslateSourceLanguageTag, + Is.EqualTo("fr"), + "the probe should translate FROM the engine's configured source language" + ); + } + + [Test] + public async Task ValidateEngineAsync_EmptySupportedList_FallsThroughToProbe() + { + var provider = new FakeValidatingProvider("alpha2", supportedTargets: new string[0]); + var collectionSettings = new CollectionSettings + { + Subscription = Subscription.CreateTempSubscriptionForTier(SubscriptionTier.Pro), + AiTranslationTargetLanguageTag = "de", + }; + var service = new AiTranslationService( + collectionSettings, + new Dictionary { { "alpha2", provider } } + ); + var engine = new AiTranslationEngineSettings { ProviderId = "alpha2", ApiKey = "key" }; + + var result = await service.ValidateEngineAsync(engine, CancellationToken.None); + + Assert.That(result.Succeeded, Is.True); + Assert.That(result.TargetLanguageNotSupported, Is.False); + Assert.That( + provider.TranslateCallCount, + Is.EqualTo(1), + "an empty supported-languages list is not proof of non-support, so we still probe" + ); + } + private static AiTranslationService MakeLanguageListService( params FakeLanguageListProvider[] providers ) @@ -397,6 +536,7 @@ public FakeAiTranslationProvider( public Task> GetSupportedTargetLanguagesAsync( AiTranslationEngineSettings engine, + IReadOnlyList likelySourceLanguageTags, HttpClient httpClient, CancellationToken ct ) @@ -449,12 +589,17 @@ public FakeLanguageListProvider( public int MaxSegmentsPerRequest => 100; public int MaxRequestBytes => 100_000; + /// The likelySourceLanguageTags the service passed on the most recent call. + public IReadOnlyList LastLikelySourceLanguageTags { get; private set; } + public Task> GetSupportedTargetLanguagesAsync( AiTranslationEngineSettings engine, + IReadOnlyList likelySourceLanguageTags, HttpClient httpClient, CancellationToken ct ) { + LastLikelySourceLanguageTags = likelySourceLanguageTags; if (_throwMessage != null) throw new HttpRequestException(_throwMessage); @@ -484,6 +629,61 @@ CancellationToken ct ); } } + + /// + /// A provider that both lists a fixed set of supported target languages AND "translates", + /// recording how the probe was called, so ValidateEngineAsync's target-support check and + /// probe-source behavior can be exercised without any network. + /// + private sealed class FakeValidatingProvider : IAiTranslationProvider + { + private readonly string[] _supportedTargets; + + public FakeValidatingProvider(string providerId, string[] supportedTargets) + { + ProviderId = providerId; + _supportedTargets = supportedTargets ?? new string[0]; + } + + public string ProviderId { get; } + public int MaxSegmentsPerRequest => 100; + public int MaxRequestBytes => 100_000; + public int TranslateCallCount { get; private set; } + public string LastTranslateSourceLanguageTag { get; private set; } + + public Task> GetSupportedTargetLanguagesAsync( + AiTranslationEngineSettings engine, + IReadOnlyList likelySourceLanguageTags, + HttpClient httpClient, + CancellationToken ct + ) + { + return Task.FromResult( + _supportedTargets + .Select(tag => new AiTranslationTargetLanguageOption + { + Value = tag, + Label = tag, + ProviderIds = new List { ProviderId }, + }) + .ToList() + ); + } + + public Task TranslateBatchAsync( + AiTranslationEngineSettings engine, + string[] segments, + string sourceLanguageTag, + string targetLanguageTag, + HttpClient httpClient, + CancellationToken ct + ) + { + TranslateCallCount++; + LastTranslateSourceLanguageTag = sourceLanguageTag; + return Task.FromResult(segments.Select(s => "translated:" + s).ToArray()); + } + } } public abstract class AiTranslationLiveTranslationTestsBase diff --git a/src/BloomTests/AiTranslation/Alpha2TranslationProviderTests.cs b/src/BloomTests/AiTranslation/Alpha2TranslationProviderTests.cs index 9da47d018b95..3929dc126bf8 100644 --- a/src/BloomTests/AiTranslation/Alpha2TranslationProviderTests.cs +++ b/src/BloomTests/AiTranslation/Alpha2TranslationProviderTests.cs @@ -78,6 +78,85 @@ public void IsTranslationComplete_UnrecognizedStatus_Throws() Assert.That(exception.Message, Does.Contain("exploded")); } + + [Test] + public void MapIso6393ToBloomTag_MapsToShortestRegisteredTag() + { + // Alpha2's 3-letter codes should collapse to the familiar 2-letter tags so they dedup + // with DeepL/Google's "fr"/"en" in the settings union. + Assert.That(Alpha2TranslationProvider.MapIso6393ToBloomTag("fra"), Is.EqualTo("fr")); + Assert.That(Alpha2TranslationProvider.MapIso6393ToBloomTag("eng"), Is.EqualTo("en")); + } + + [Test] + public void MapIso6393ToBloomTag_UnmappedCodePassesThrough() + { + // A code with no shorter registered tag is returned unchanged (and lowercased). + Assert.That(Alpha2TranslationProvider.MapIso6393ToBloomTag("zzz"), Is.EqualTo("zzz")); + Assert.That(Alpha2TranslationProvider.MapIso6393ToBloomTag("FRA"), Is.EqualTo("fr")); + } + + [Test] + public void ParseTranslationLanguages_ReverseMapsSortsByLabelAndTagsAlpha2() + { + var json = + @"[ + {""iso"":""fra"",""name"":""French"",""display"":""Français"",""models"":[{""id"":1,""name"":""m1""}]}, + {""iso"":""spa"",""name"":""Spanish"",""models"":[]}, + {""iso"":""zzz"",""name"":""Madeup""} + ]"; + + var options = Alpha2TranslationProvider.ParseTranslationLanguages(json); + + // Sorted by label: French, Madeup, Spanish. + Assert.That( + options.Select(o => o.Label), + Is.EqualTo(new[] { "French", "Madeup", "Spanish" }) + ); + var valueByLabel = options.ToDictionary(o => o.Label, o => o.Value); + Assert.That(valueByLabel["French"], Is.EqualTo("fr")); + Assert.That(valueByLabel["Spanish"], Is.EqualTo("es")); + Assert.That( + valueByLabel["Madeup"], + Is.EqualTo("zzz"), + "an unmapped ISO code should pass through unchanged" + ); + Assert.That( + options.All(o => o.ProviderIds.SequenceEqual(new[] { "alpha2" })), + Is.True, + "every option must be tagged as coming from alpha2" + ); + } + + [Test] + public void ParseTranslationLanguages_DedupsEntriesThatMapToTheSameTag() + { + var json = + @"[ + {""iso"":""eng"",""name"":""English""}, + {""iso"":""eng"",""name"":""English (again)""} + ]"; + + var options = Alpha2TranslationProvider.ParseTranslationLanguages(json); + + Assert.That(options.Count, Is.EqualTo(1)); + Assert.That(options[0].Value, Is.EqualTo("en")); + } + + [Test] + public void ParseTranslationLanguages_SkipsEntriesWithNoIso() + { + var json = + @"[ + {""name"":""No iso here""}, + {""iso"":""fra"",""name"":""French""} + ]"; + + var options = Alpha2TranslationProvider.ParseTranslationLanguages(json); + + Assert.That(options.Count, Is.EqualTo(1)); + Assert.That(options[0].Value, Is.EqualTo("fr")); + } } /// From 2e866cf1893d8db276ea1278a342d0efee044440 Mon Sep 17 00:00:00 2001 From: Hatton Date: Wed, 15 Jul 2026 16:05:10 -0600 Subject: [PATCH 09/10] Fix Alpha2 cleanup on user cancel: use CancellationToken.None (BL-16549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the failure/cancel path, the best-effort deletes of the source and output text collections were passed the request's CancellationToken. When the user cancels, that token is already tripped, so the first cleanup SendAsync threw OperationCanceledException immediately and (being best-effort) was swallowed — orphaning the storage-consuming Alpha2 collections on every cancel. Pass CancellationToken.None so cleanup still runs after cancellation. Reported by Greptile. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AiTranslation/Alpha2TranslationProvider.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs b/src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs index 2f5fb929c8f8..0d8600210d1a 100644 --- a/src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs +++ b/src/BloomExe/AiTranslation/Alpha2TranslationProvider.cs @@ -356,14 +356,22 @@ CancellationToken ct // Best-effort cleanup on the failure path: a collection is still a real, // storage-consuming resource even when translation failed, but a delete failure // here must never replace/mask the original, more informative exception. - await TryDeleteBestEffortAsync(httpClient, apiKey, sourceCollectionId, ct); + // Use CancellationToken.None: this path also runs when the user cancels (ct is + // already tripped), and passing ct would make the very first cleanup request throw + // OperationCanceledException, silently orphaning the collections we came here to delete. + await TryDeleteBestEffortAsync( + httpClient, + apiKey, + sourceCollectionId, + CancellationToken.None + ); if (outputCollectionId.HasValue) { await TryDeleteBestEffortAsync( httpClient, apiKey, outputCollectionId.Value, - ct + CancellationToken.None ); } throw; From 0c5ce29edf7bc54ae03e6e579bc415a2d0e8f4c9 Mon Sep 17 00:00:00 2001 From: Hatton Date: Wed, 15 Jul 2026 16:25:09 -0600 Subject: [PATCH 10/10] Fix engine validation stuck in "Testing..." on unrelated setting change (BL-16549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aiTranslationFlatSettings memo keyed on the whole `settings` object, but extractAiTranslationFlatSettings returns a fresh object each call, so any unrelated Advanced-tab setting change handed the AI settings group a new reference. Mid-validation that cancelled the in-flight probe (its finally then skipped clearing the pending flag because it was cancelled) and the re-run hit the probe-key early-return without restarting — leaving the row stuck on "Testing translation..." forever. Key the memo on the serialized AI settings so its reference only changes when the AI settings actually change. Reported by Devin. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../collection/AdvancedSettingsPanel.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx index 1ccc41ff761f..52373527d24c 100644 --- a/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx +++ b/src/BloomBrowserUI/collection/AdvancedSettingsPanel.tsx @@ -262,14 +262,24 @@ export const AdvancedSettingsPanel: React.FunctionComponent = () => { // Memoized so the object reference is stable across renders that don't actually change // the AI settings; the group's validation/language-fetch effects key off this reference. + // We key the memo on the *serialized* AI settings rather than the whole `settings` object: + // extractAiTranslationFlatSettings returns a fresh object every call, so keying on `settings` + // handed out a new reference whenever ANY unrelated setting changed. That silently cancelled an + // in-flight engine validation and left it stuck showing "Testing translation..." forever (the + // per-engine validation effect early-returns when its probe key is unchanged, so it never + // restarted or cleared the pending flag). Keying on the serialized value fixes that. + const extractedAiTranslationFlatSettings = settings + ? extractAiTranslationFlatSettings( + settings as unknown as Record, + ) + : undefined; + const aiTranslationFlatSettingsKey = JSON.stringify( + extractedAiTranslationFlatSettings ?? null, + ); const aiTranslationFlatSettings = React.useMemo( - () => - settings - ? extractAiTranslationFlatSettings( - settings as unknown as Record, - ) - : undefined, - [settings], + () => extractedAiTranslationFlatSettings, + // eslint-disable-next-line react-hooks/exhaustive-deps + [aiTranslationFlatSettingsKey], ); const {