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(/&amp;/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, "&amp;")
474    .replace(/\"/g, "&quot;")
475    .replace(/\'/g, "&apos;")
476    .replace(/</g, "&lt;")
477    .replace(/>/g, "&gt;");
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