1/* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5"use strict"; 6 7var EXPORTED_SYMBOLS = ["BingTranslator"]; 8 9const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 10const { PromiseUtils } = ChromeUtils.import( 11 "resource://gre/modules/PromiseUtils.jsm" 12); 13const { Async } = ChromeUtils.import("resource://services-common/async.js"); 14const { httpRequest } = ChromeUtils.import("resource://gre/modules/Http.jsm"); 15const { XPCOMUtils } = ChromeUtils.import( 16 "resource://gre/modules/XPCOMUtils.jsm" 17); 18 19XPCOMUtils.defineLazyGlobalGetters(this, ["XMLHttpRequest"]); 20 21// The maximum amount of net data allowed per request on Bing's API. 22const MAX_REQUEST_DATA = 5000; // Documentation says 10000 but anywhere 23// close to that is refused by the service. 24 25// The maximum number of chunks allowed to be translated in a single 26// request. 27const MAX_REQUEST_CHUNKS = 1000; // Documentation says 2000. 28 29// Self-imposed limit of 15 requests. This means that a page that would need 30// to be broken in more than 15 requests won't be fully translated. 31// The maximum amount of data that we will translate for a single page 32// is MAX_REQUESTS * MAX_REQUEST_DATA. 33const MAX_REQUESTS = 15; 34 35/** 36 * Translates a webpage using Bing's Translation API. 37 * 38 * @param translationDocument The TranslationDocument object that represents 39 * the webpage to be translated 40 * @param sourceLanguage The source language of the document 41 * @param targetLanguage The target language for the translation 42 * 43 * @returns {Promise} A promise that will resolve when the translation 44 * task is finished. 45 */ 46var BingTranslator = function( 47 translationDocument, 48 sourceLanguage, 49 targetLanguage 50) { 51 this.translationDocument = translationDocument; 52 this.sourceLanguage = sourceLanguage; 53 this.targetLanguage = targetLanguage; 54 this._pendingRequests = 0; 55 this._partialSuccess = false; 56 this._serviceUnavailable = false; 57 this._translatedCharacterCount = 0; 58}; 59 60BingTranslator.prototype = { 61 /** 62 * Performs the translation, splitting the document into several chunks 63 * respecting the data limits of the API. 64 * 65 * @returns {Promise} A promise that will resolve when the translation 66 * task is finished. 67 */ 68 translate() { 69 return (async () => { 70 let currentIndex = 0; 71 this._onFinishedDeferred = PromiseUtils.defer(); 72 73 // Let's split the document into various requests to be sent to 74 // Bing's Translation API. 75 for (let requestCount = 0; requestCount < MAX_REQUESTS; requestCount++) { 76 // Generating the text for each request can be expensive, so 77 // let's take the opportunity of the chunkification process to 78 // allow for the event loop to attend other pending events 79 // before we continue. 80 await Async.promiseYield(); 81 82 // Determine the data for the next request. 83 let request = this._generateNextTranslationRequest(currentIndex); 84 85 // Create a real request to the server, and put it on the 86 // pending requests list. 87 let bingRequest = new BingRequest( 88 request.data, 89 this.sourceLanguage, 90 this.targetLanguage 91 ); 92 this._pendingRequests++; 93 bingRequest 94 .fireRequest() 95 .then(this._chunkCompleted.bind(this), this._chunkFailed.bind(this)); 96 97 currentIndex = request.lastIndex; 98 if (request.finished) { 99 break; 100 } 101 } 102 103 return this._onFinishedDeferred.promise; 104 })(); 105 }, 106 107 /** 108 * Resets the expiration time of the current token, in order to 109 * force the token manager to ask for a new token during the next request. 110 */ 111 _resetToken() { 112 // Force the token manager to get update token 113 BingTokenManager._currentExpiryTime = 0; 114 }, 115 116 /** 117 * Function called when a request sent to the server completed successfully. 118 * This function handles calling the function to parse the result and the 119 * function to resolve the promise returned by the public `translate()` 120 * method when there's no pending request left. 121 * 122 * @param request The BingRequest sent to the server. 123 */ 124 _chunkCompleted(bingRequest) { 125 if (this._parseChunkResult(bingRequest)) { 126 this._partialSuccess = true; 127 // Count the number of characters successfully translated. 128 this._translatedCharacterCount += bingRequest.characterCount; 129 } 130 131 this._checkIfFinished(); 132 }, 133 134 /** 135 * Function called when a request sent to the server has failed. 136 * This function handles deciding if the error is transient or means the 137 * service is unavailable (zero balance on the key or request credentials are 138 * not in an active state) and calling the function to resolve the promise 139 * returned by the public `translate()` method when there's no pending. 140 * request left. 141 * 142 * @param aError [optional] The XHR object of the request that failed. 143 */ 144 _chunkFailed(aError) { 145 if ( 146 aError instanceof XMLHttpRequest && 147 [400, 401].includes(aError.status) 148 ) { 149 let body = aError.responseText; 150 if ( 151 body && 152 body.includes("TranslateApiException") && 153 (body.includes("balance") || body.includes("active state")) 154 ) { 155 this._serviceUnavailable = true; 156 } 157 } 158 159 this._checkIfFinished(); 160 }, 161 162 /** 163 * Function called when a request sent to the server has completed. 164 * This function handles resolving the promise 165 * returned by the public `translate()` method when all chunks are completed. 166 */ 167 _checkIfFinished() { 168 // Check if all pending requests have been 169 // completed and then resolves the promise. 170 // If at least one chunk was successful, the 171 // promise will be resolved positively which will 172 // display the "Success" state for the infobar. Otherwise, 173 // the "Error" state will appear. 174 if (--this._pendingRequests == 0) { 175 if (this._partialSuccess) { 176 this._onFinishedDeferred.resolve({ 177 characterCount: this._translatedCharacterCount, 178 }); 179 } else { 180 let error = this._serviceUnavailable ? "unavailable" : "failure"; 181 this._onFinishedDeferred.reject(error); 182 } 183 } 184 }, 185 186 /** 187 * This function parses the result returned by Bing's Http.svc API, 188 * which is a XML file that contains a number of elements. To our 189 * particular interest, the only part of the response that matters 190 * are the <TranslatedText> nodes, which contains the resulting 191 * items that were sent to be translated. 192 * 193 * @param request The request sent to the server. 194 * @returns boolean True if parsing of this chunk was successful. 195 */ 196 _parseChunkResult(bingRequest) { 197 let results; 198 try { 199 let doc = bingRequest.networkRequest.responseXML; 200 results = doc.querySelectorAll("TranslatedText"); 201 } catch (e) { 202 return false; 203 } 204 205 let len = results.length; 206 if (len != bingRequest.translationData.length) { 207 // This should never happen, but if the service returns a different number 208 // of items (from the number of items submitted), we can't use this chunk 209 // because all items would be paired incorrectly. 210 return false; 211 } 212 213 let error = false; 214 for (let i = 0; i < len; i++) { 215 try { 216 let result = results[i].firstChild.nodeValue; 217 let root = bingRequest.translationData[i][0]; 218 219 if (root.isSimpleRoot) { 220 // Workaround for Bing's service problem in which "&" chars in 221 // plain-text TranslationItems are double-escaped. 222 result = result.replace(/&/g, "&"); 223 } 224 225 root.parseResult(result); 226 } catch (e) { 227 error = true; 228 } 229 } 230 231 return !error; 232 }, 233 234 /** 235 * This function will determine what is the data to be used for 236 * the Nth request we are generating, based on the input params. 237 * 238 * @param startIndex What is the index, in the roots list, that the 239 * chunk should start. 240 */ 241 _generateNextTranslationRequest(startIndex) { 242 let currentDataSize = 0; 243 let currentChunks = 0; 244 let output = []; 245 let rootsList = this.translationDocument.roots; 246 247 for (let i = startIndex; i < rootsList.length; i++) { 248 let root = rootsList[i]; 249 let text = this.translationDocument.generateTextForItem(root); 250 if (!text) { 251 continue; 252 } 253 254 text = escapeXML(text); 255 let newCurSize = currentDataSize + text.length; 256 let newChunks = currentChunks + 1; 257 258 if (newCurSize > MAX_REQUEST_DATA || newChunks > MAX_REQUEST_CHUNKS) { 259 // If we've reached the API limits, let's stop accumulating data 260 // for this request and return. We return information useful for 261 // the caller to pass back on the next call, so that the function 262 // can keep working from where it stopped. 263 return { 264 data: output, 265 finished: false, 266 lastIndex: i, 267 }; 268 } 269 270 currentDataSize = newCurSize; 271 currentChunks = newChunks; 272 output.push([root, text]); 273 } 274 275 return { 276 data: output, 277 finished: true, 278 lastIndex: 0, 279 }; 280 }, 281}; 282 283/** 284 * Represents a request (for 1 chunk) sent off to Bing's service. 285 * 286 * @params translationData The data to be used for this translation, 287 * generated by the generateNextTranslationRequest... 288 * function. 289 * @param sourceLanguage The source language of the document. 290 * @param targetLanguage The target language for the translation. 291 * 292 */ 293function BingRequest(translationData, sourceLanguage, targetLanguage) { 294 this.translationData = translationData; 295 this.sourceLanguage = sourceLanguage; 296 this.targetLanguage = targetLanguage; 297 this.characterCount = 0; 298} 299 300BingRequest.prototype = { 301 /** 302 * Initiates the request 303 */ 304 fireRequest() { 305 return (async () => { 306 // Prepare authentication. 307 let token = await BingTokenManager.getToken(); 308 let auth = "Bearer " + token; 309 310 // Prepare URL. 311 let url = getUrlParam( 312 "https://api.microsofttranslator.com/v2/Http.svc/TranslateArray", 313 "browser.translation.bing.translateArrayURL" 314 ); 315 316 // Prepare request headers. 317 let headers = [ 318 ["Content-type", "text/xml"], 319 ["Authorization", auth], 320 ]; 321 322 // Prepare the request body. 323 let requestString = 324 "<TranslateArrayRequest>" + 325 "<AppId/>" + 326 "<From>" + 327 this.sourceLanguage + 328 "</From>" + 329 "<Options>" + 330 '<ContentType xmlns="http://schemas.datacontract.org/2004/07/Microsoft.MT.Web.Service.V2">text/html</ContentType>' + 331 '<ReservedFlags xmlns="http://schemas.datacontract.org/2004/07/Microsoft.MT.Web.Service.V2" />' + 332 "</Options>" + 333 '<Texts xmlns:s="http://schemas.microsoft.com/2003/10/Serialization/Arrays">'; 334 335 for (let [, text] of this.translationData) { 336 requestString += "<s:string>" + text + "</s:string>"; 337 this.characterCount += text.length; 338 } 339 340 requestString += 341 "</Texts>" + 342 "<To>" + 343 this.targetLanguage + 344 "</To>" + 345 "</TranslateArrayRequest>"; 346 347 // Set up request options. 348 return new Promise((resolve, reject) => { 349 let options = { 350 onLoad: (responseText, xhr) => { 351 resolve(this); 352 }, 353 onError(e, responseText, xhr) { 354 reject(xhr); 355 }, 356 postData: requestString, 357 headers, 358 }; 359 360 // Fire the request. 361 let request = httpRequest(url, options); 362 363 // Override the response MIME type. 364 request.overrideMimeType("text/xml"); 365 this.networkRequest = request; 366 }); 367 })(); 368 }, 369}; 370 371/** 372 * Authentication Token manager for the API 373 */ 374var BingTokenManager = { 375 _currentToken: null, 376 _currentExpiryTime: 0, 377 _pendingRequest: null, 378 379 /** 380 * Get a valid, non-expired token to be used for the API calls. 381 * 382 * @returns {Promise} A promise that resolves with the token 383 * string once it is obtained. The token returned 384 * can be the same one used in the past if it is still 385 * valid. 386 */ 387 getToken() { 388 if (this._pendingRequest) { 389 return this._pendingRequest; 390 } 391 392 let remainingMs = this._currentExpiryTime - new Date(); 393 // Our existing token is still good for more than a minute, let's use it. 394 if (remainingMs > 60 * 1000) { 395 return Promise.resolve(this._currentToken); 396 } 397 398 return this._getNewToken(); 399 }, 400 401 /** 402 * Generates a new token from the server. 403 * 404 * @returns {Promise} A promise that resolves with the token 405 * string once it is obtained. 406 */ 407 _getNewToken() { 408 let url = getUrlParam( 409 "https://datamarket.accesscontrol.windows.net/v2/OAuth2-13", 410 "browser.translation.bing.authURL" 411 ); 412 let params = [ 413 ["grant_type", "client_credentials"], 414 ["scope", "http://api.microsofttranslator.com"], 415 [ 416 "client_id", 417 getUrlParam( 418 "%BING_API_CLIENTID%", 419 "browser.translation.bing.clientIdOverride" 420 ), 421 ], 422 [ 423 "client_secret", 424 getUrlParam( 425 "%BING_API_KEY%", 426 "browser.translation.bing.apiKeyOverride" 427 ), 428 ], 429 ]; 430 431 this._pendingRequest = new Promise((resolve, reject) => { 432 let options = { 433 onLoad(responseText, xhr) { 434 BingTokenManager._pendingRequest = null; 435 try { 436 let json = JSON.parse(responseText); 437 438 if (json.error) { 439 reject(json.error); 440 return; 441 } 442 443 let token = json.access_token; 444 let expires_in = json.expires_in; 445 BingTokenManager._currentToken = token; 446 BingTokenManager._currentExpiryTime = new Date( 447 Date.now() + expires_in * 1000 448 ); 449 resolve(token); 450 } catch (e) { 451 reject(e); 452 } 453 }, 454 onError(e, responseText, xhr) { 455 BingTokenManager._pendingRequest = null; 456 reject(e); 457 }, 458 postData: params, 459 }; 460 461 httpRequest(url, options); 462 }); 463 return this._pendingRequest; 464 }, 465}; 466 467/** 468 * Escape a string to be valid XML content. 469 */ 470function escapeXML(aStr) { 471 return aStr 472 .toString() 473 .replace(/&/g, "&") 474 .replace(/\"/g, """) 475 .replace(/\'/g, "'") 476 .replace(/</g, "<") 477 .replace(/>/g, ">"); 478} 479 480/** 481 * Fetch an auth token (clientID or client secret), which may be overridden by 482 * a pref if it's set. 483 */ 484function getUrlParam(paramValue, prefName) { 485 if (Services.prefs.getPrefType(prefName)) { 486 paramValue = Services.prefs.getCharPref(prefName); 487 } 488 paramValue = Services.urlFormatter.formatURL(paramValue); 489 return paramValue; 490} 491