From 47372be18e558f83b02b9a012564a8cbb02f3e3d Mon Sep 17 00:00:00 2001 From: chughts Date: Fri, 8 Jun 2018 13:44:15 +0100 Subject: [PATCH 1/6] Add IAM Warning for STT Recognize --- services/speech_to_text/v1.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/speech_to_text/v1.js b/services/speech_to_text/v1.js index a6fd0c66..71249dae 100644 --- a/services/speech_to_text/v1.js +++ b/services/speech_to_text/v1.js @@ -17,6 +17,7 @@ module.exports = function (RED) { const SERVICE_IDENTIFIER = 'speech-to-text'; var temp = require('temp'), + request = require('request'), url = require('url'), fs = require('fs'), WebSocket = require('ws'), @@ -298,6 +299,7 @@ module.exports = function (RED) { return tokenService; } + function performSTT(speech_to_text, audioData) { var p = new Promise(function resolver(resolve, reject){ var model = config.lang + '_' + config.band, @@ -330,6 +332,7 @@ module.exports = function (RED) { resolve(res); } }); + }); return p; } @@ -637,6 +640,9 @@ module.exports = function (RED) { if (config['streaming-mode']) { return performStreamSTT(sttService, audioData); } else { + if (apikey) { + node.warn('STT Speech Recognition may not work with API Key!'); + } return performSTT(sttService, audioData); } }) From fefd686e678c7b8822b5432bbc8b91f0aad54085 Mon Sep 17 00:00:00 2001 From: chughts Date: Mon, 11 Jun 2018 16:25:57 +0100 Subject: [PATCH 2/6] Fix to STT Recognize with IAM Key --- services/speech_to_text/stt-utils.js | 2 +- services/speech_to_text/v1.js | 110 +++++++++++++++++++++++- utilities/iam-utils.js | 123 +++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 utilities/iam-utils.js diff --git a/services/speech_to_text/stt-utils.js b/services/speech_to_text/stt-utils.js index fa57fdd0..00602fdf 100644 --- a/services/speech_to_text/stt-utils.js +++ b/services/speech_to_text/stt-utils.js @@ -74,7 +74,7 @@ class STTUtils { if (endpoint) { serviceSettings.url = endpoint; } - + return new STTV1(serviceSettings); } diff --git a/services/speech_to_text/v1.js b/services/speech_to_text/v1.js index 71249dae..2f047f64 100644 --- a/services/speech_to_text/v1.js +++ b/services/speech_to_text/v1.js @@ -22,8 +22,10 @@ module.exports = function (RED) { fs = require('fs'), WebSocket = require('ws'), fileType = require('file-type'), + pkg = require('../../package.json'), serviceutils = require('../../utilities/service-utils'), payloadutils = require('../../utilities/payload-utils'), + iamutils = require('../../utilities/iam-utils'), sttutils = require('./stt-utils'), AuthV1 = require('watson-developer-cloud/authorization/v1'), AuthIAMV1 = require('watson-developer-cloud/iam-token-manager/v1'), @@ -268,7 +270,21 @@ module.exports = function (RED) { } function getService() { - return Promise.resolve(determineService()); + var p = new Promise(function resolver(resolve, reject){ + let sttService = determineService(); + if (apikey) { + sttService.preAuthenticate((ready) => { + if (!ready) { + reject('Service is not ready'); + } else { + resolve(sttService); + } + }); + } else { + resolve(sttService); + } + }); + return p; } function determineTokenService(stt) { @@ -299,6 +315,95 @@ module.exports = function (RED) { return tokenService; } + function cloneQS(original) { + // First create an empty object that will receive copies of properties + let clone = {}, i, keys = Object.keys(original); + + for (i = 0; i < keys.length; i++) { + // copy each property into the clone + clone[keys[i]] = original[keys[i]]; + } + ['audio', 'content_type'].forEach((f) => { + if (clone[f]) { + delete clone[f]; + } + }); + + return clone; + } + + function buildRequestSettings(params, t) { + let requestSettings = { + qs : cloneQS(params), + method : 'POST', + uri : endpoint + '/recognize', + headers : { + //Authorization: "Bearer " + t, + 'Content-Type': params.content_type, + 'User-Agent': pkg.name + '-' + pkg.version, + 'Accept': 'application/json', + }, + iam_apikey: apikey, + auth: { + 'bearer': t + }, + body : params.audio + }; + + return Promise.resolve(requestSettings); + } + + function executePostRequest(requestSettings) { + var p = new Promise(function resolver(resolve, reject){ + request(requestSettings, (error, response, body) => { + console.log('--------- request has been executed ---------------'); + + if (!error && response.statusCode == 200) { + data = JSON.parse(body); + resolve(data); + } else if (error) { + reject(error); + } else { + let errordata = JSON.parse(body); + console.log(errordata); + if (errordata.errors && + Array.isArray(errordata.errors) && + errordata.errors.length && + errordata.errors[0].message) { + reject('Error ' + response.statusCode + ' ' + errordata.errors[0].message); + } else { + reject('Error performing request ' + response.statusCode); + } + } + + }); + }); + return p; + } + + function iamRecognize(params) { + var p = new Promise(function resolver(resolve, reject){ + //console.log('qs params look like ', qs); + // The token may have expired so test for it. + //getToken(speech_to_text) + iamutils.getIAMToken(apikey) + .then((t) => { + //console.log('We should now have a token ', token); + return buildRequestSettings(params, t); + }) + .then((requestSettings) => { + //console.log('Request parameters look like ', requestSettings); + return executePostRequest(requestSettings); + }) + .then((data) => { + resolve(data); + }) + .catch((err) => { + reject(err); + }) + }); + return p; + } function performSTT(speech_to_text, audioData) { var p = new Promise(function resolver(resolve, reject){ @@ -640,9 +745,6 @@ module.exports = function (RED) { if (config['streaming-mode']) { return performStreamSTT(sttService, audioData); } else { - if (apikey) { - node.warn('STT Speech Recognition may not work with API Key!'); - } return performSTT(sttService, audioData); } }) diff --git a/utilities/iam-utils.js b/utilities/iam-utils.js new file mode 100644 index 00000000..a7e1f501 --- /dev/null +++ b/utilities/iam-utils.js @@ -0,0 +1,123 @@ +/** + * Copyright 2018 IBM Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ +const request = require('request'); + +// We are only looking at the token expiry, then refreshing that if +// needed. An alternative would be to refresh the token, before expiry +// using the refresh token, and checking if the refresh token has expired, +// but the token time has proven to be sufficient so far. If not will need +// to make the change to add refresh token processing. + +class IAMUtils { + + constructor() { + } + + static stashToken(key, tokenInfo) { + if (! IAMUtils.tokenStash) { + IAMUtils.tokenStash = {}; + } + IAMUtils.tokenStash[key] = {}; + ['access_token', 'refresh_token', 'expires_in', 'expiration'].forEach((f) => { + IAMUtils.tokenStash[key][f] = tokenInfo[f] ? tokenInfo[f] : null; + }) + } + + static checkForToken(key) { + return IAMUtils.tokenStash && + IAMUtils.tokenStash[key] && + IAMUtils.tokenStash[key].access_token; + } + + static notExpiredToken(key) { + let fractionOfTtl = 0.8, + timeToLive = IAMUtils.tokenStash[key].expires_in, + expireTime = IAMUtils.tokenStash[key].expiration, + currentTime = Math.floor(Date.now() / 1000), + refreshTime = expireTime - (timeToLive * (1.0 - fractionOfTtl)); + + return refreshTime >= currentTime; + } + + static lookInStash(key){ + if (IAMUtils.checkForToken(key) && IAMUtils.notExpiredToken(key)) { + return Promise.resolve(IAMUtils.tokenStash[key].access_token); + } + return Promise.reject(); + } + + static getNewIAMToken(key) { + var p = new Promise(function resolver(resolve, reject){ + let iamtoken = null, + uriAddress = 'https://iam.bluemix.net/identity/token'; + + request({ + uri: uriAddress, + method: 'POST', + headers : { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + form: { + grant_type: 'urn:ibm:params:oauth:grant-type:apikey', + apikey: key, + response_type: 'cloud_iam' + } + }, (error, response, body) => { + if (!error && response.statusCode == 200) { + //console.log('Token body looks like : ', body); + var b = JSON.parse(body); + //console.log('Token body looks like : ', b); + IAMUtils.stashToken(key, b); + if (b.access_token) { + iamtoken = b.access_token; + } + resolve(iamtoken); + } else if (error) { + reject(error); + } else { + //console.log(body); + reject('IAM Access Token Error ' + response.statusCode); + } + }); + }); + return p; + } + + static getIAMToken(key) { + var p = new Promise(function resolver(resolve, reject){ + IAMUtils.lookInStash(key) + .then((iamtoken) => { + //console.log('Resolving from stash'); + resolve(iamtoken); + }) + .catch(() => { + //console.log('Resolving from new'); + IAMUtils.getNewIAMToken(key) + .then((iamtoken) => { + resolve(iamtoken) + }) + .catch((err) => { + reject(err) + }) + }); + }); + return p; + } + +} + +module.exports = IAMUtils; From 1aeeae2e21ee84361fa8744effe905e7a622ad9d Mon Sep 17 00:00:00 2001 From: chughts Date: Wed, 13 Jun 2018 12:57:12 +0100 Subject: [PATCH 3/6] Implement own IAM Token retrieval --- README.md | 7 +++-- package.json | 8 ++--- services/speech_to_text/v1.js | 59 ++++++++++++++++++++++------------- utilities/iam-utils.js | 14 ++++++++- 4 files changed, 60 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 4a7578e1..2add129d 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,17 @@ Node-RED Watson Nodes for IBM Cloud CLA assistant ### New in version 0.7.0 -- Assistant, Discovery, Natural Language Understanding, Personality Insights, +- In this release STT in Stream mode with IAM Keys does not work. +- Assistant, Discovery, Language Identify, Language Translator, +Natural Language Understanding, Personality Insights, Speech to Text, Text to Speech, Tone Analyzer nodes updated -to allow for use of iam key for authentication. +to allow for use of IAM key for authentication. - Migrated STT node off deprecated methods. - Fix to Tone Analyzer Node to preserve credentials on config reopen. - Fix to Tone Analyzer to allow json objects and arrays as payload. - Fix to STT where auto-connect was not being preserved when reopening configuration. - Bump to 2018-03-05 version date for Discovery service. +- Move to V3 of Language Translator - Migrated Discovery Nodes off deprecated methods. - Remove Deprecated Retrieve and Rank Nodes diff --git a/package.json b/package.json index df25b1f8..efddd9b0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "temp": "^0.8.3", "qs": "6.x", "image-type": "^2.0.2", - "watson-developer-cloud": "^3.4.5", + "watson-developer-cloud": "^3.5.0", "kuromoji": "^0.1.1", "word-count": "^0.2.2", "is-docx": "^0.0.3", @@ -40,9 +40,9 @@ "watson-discovery-v1": "services/discovery/v1.js", "watson-discovery-v1-document-loader": "services/discovery/v1-document-loader.js", "watson-discovery-v1-query-builder": "services/discovery/v1-query-builder.js", - "watson-language-translator-v2": "services/language_translator/v2.js", - "watson-language-translator-identify-v2": "services/language_translator_identify/v2.js", - "watson-language-translator-util-v2": "services/language_translator_util/v2.js", + "watson-language-translator-v3": "services/language_translator/v3.js", + "watson-language-translator-identify-v3": "services/language_translator_identify/v3.js", + "watson-language-translator-util-v3": "services/language_translator_util/v3.js", "watson-natural-language-classifier-v1": "services/natural_language_classifier/v1.js", "watson-natural-language-understanding-v1": "services/natural_language_understanding/v1.js", "watson-personality-insights-v3": "services/personality_insights/v3.js", diff --git a/services/speech_to_text/v1.js b/services/speech_to_text/v1.js index 2f047f64..09edf937 100644 --- a/services/speech_to_text/v1.js +++ b/services/speech_to_text/v1.js @@ -272,17 +272,21 @@ module.exports = function (RED) { function getService() { var p = new Promise(function resolver(resolve, reject){ let sttService = determineService(); - if (apikey) { - sttService.preAuthenticate((ready) => { - if (!ready) { - reject('Service is not ready'); - } else { - resolve(sttService); - } - }); - } else { - resolve(sttService); - } + // preAuthenticate was a temp fix, but now that we are + // processing IAM Keys directly, no need for this, but + // we will keep the commented out code for now, as we + // may come back to this. + // if (apikey) { + // sttService.preAuthenticate((ready) => { + // if (!ready) { + // reject('Service is not ready'); + // } else { + // resolve(sttService); + // } + // }); + // } else { + resolve(sttService); + // } }); return p; } @@ -298,7 +302,9 @@ module.exports = function (RED) { // creds.iamApikey = apikey; // console.log('Creating token with endpoint ', endpoint); // tokenService = new AuthIAMV1.IamTokenManagerV1({iamApikey : apikey, iamUrl: endpoint}); + tokenService = new AuthIAMV1.IamTokenManagerV1({iamApikey : apikey}); + //tokenService = new iamutils(apikey); } else { // console.log('Standard Key'); @@ -336,7 +342,7 @@ module.exports = function (RED) { let requestSettings = { qs : cloneQS(params), method : 'POST', - uri : endpoint + '/recognize', + uri : endpoint + '/v1/recognize', headers : { //Authorization: "Bearer " + t, 'Content-Type': params.content_type, @@ -356,7 +362,7 @@ module.exports = function (RED) { function executePostRequest(requestSettings) { var p = new Promise(function resolver(resolve, reject){ request(requestSettings, (error, response, body) => { - console.log('--------- request has been executed ---------------'); + //console.log('--------- request has been executed ---------------'); if (!error && response.statusCode == 200) { data = JSON.parse(body); @@ -371,6 +377,8 @@ module.exports = function (RED) { errordata.errors.length && errordata.errors[0].message) { reject('Error ' + response.statusCode + ' ' + errordata.errors[0].message); + } else if (errordata.error) { + reject('Error performing request ' + errordata.error); } else { reject('Error performing request ' + response.statusCode); } @@ -392,7 +400,6 @@ module.exports = function (RED) { return buildRequestSettings(params, t); }) .then((requestSettings) => { - //console.log('Request parameters look like ', requestSettings); return executePostRequest(requestSettings); }) .then((data) => { @@ -430,13 +437,23 @@ module.exports = function (RED) { } // Everything is now in place to invoke the service - speech_to_text.recognize(params, function (err, res) { - if (err) { + if (apikey) { + iamRecognize(params) + .then((data) => { + resolve(data); + }) + .catch((err) => { reject(err); - } else { - resolve(res); - } + }) + } else { + speech_to_text.recognize(params, function (err, res) { + if (err) { + reject(err); + } else { + resolve(res); + } }); + } }); return p; @@ -466,7 +483,7 @@ module.exports = function (RED) { tokenPending = false; tokenTime = now; token = res; - // console.log('We have the token ', token); + //console.log('We have the token ', token); resolve(); } }); @@ -495,7 +512,7 @@ module.exports = function (RED) { + '?watson-token=' + token + '&model=' + model; } - // console.log('wsURI is : ', wsURI); + //console.log('wsURI is : ', wsURI); if (!websocket && !socketCreationInProcess) { socketCreationInProcess = true; diff --git a/utilities/iam-utils.js b/utilities/iam-utils.js index a7e1f501..3313bf58 100644 --- a/utilities/iam-utils.js +++ b/utilities/iam-utils.js @@ -23,7 +23,18 @@ const request = require('request'); class IAMUtils { - constructor() { + constructor(key) { + this._key = key; + } + + getToken(cb) { + IAMUtils.getIAMToken(this._key) + .then((token) => { + cb(null, token); + }) + .catch((err) => { + cb(err, null); + }); } static stashToken(key, tokenInfo) { @@ -97,6 +108,7 @@ class IAMUtils { return p; } + static getIAMToken(key) { var p = new Promise(function resolver(resolve, reject){ IAMUtils.lookInStash(key) From 1b75379cda4212d58ee7abe12812abf291a0533d Mon Sep 17 00:00:00 2001 From: chughts Date: Wed, 13 Jun 2018 12:59:22 +0100 Subject: [PATCH 4/6] Implement IAM Key processing for Translator nodes --- .../language_translator/{v2.html => v3.html} | 24 +++++++-- services/language_translator/{v2.js => v3.js} | 49 ++++++++++++------- .../{v2.html => v3.html} | 9 +++- .../{v2.js => v3.js} | 32 +++++++----- .../{v2.html => v3.html} | 9 +++- .../language_translator_util/{v2.js => v3.js} | 38 +++++++++----- 6 files changed, 115 insertions(+), 46 deletions(-) rename services/language_translator/{v2.html => v3.html} (97%) rename services/language_translator/{v2.js => v3.js} (91%) rename services/language_translator_identify/{v2.html => v3.html} (92%) rename services/language_translator_identify/{v2.js => v3.js} (80%) rename services/language_translator_util/{v2.html => v3.html} (94%) rename services/language_translator_util/{v2.js => v3.js} (88%) diff --git a/services/language_translator/v2.html b/services/language_translator/v3.html similarity index 97% rename from services/language_translator/v2.html rename to services/language_translator/v3.html index 9d34ec32..0433ed70 100644 --- a/services/language_translator/v2.html +++ b/services/language_translator/v3.html @@ -38,6 +38,10 @@ +
@@ -237,6 +241,7 @@ 'ru': 'Russian', 'tr': 'Turkish', 'zh': 'Chinese', + 'zh-TW' : 'Taiwanese', 'zht': 'Traditional Chinese' }; @@ -307,8 +312,9 @@ tor.checkModels = function () { var u = $('#node-input-username').val(); var p = $('#node-input-password').val(); + var k = $('#node-input-apikey').val(); var e = $('#node-input-service-endpoint').val(); - var creds = {un: u, pwd: p}; + var creds = {un: u, pwd: p, key: k}; if ($('#node-input-neural').prop('checked')) { creds.n = 'Y'; @@ -335,8 +341,9 @@ tor.getCredentials = function () { var u = $('#node-input-username').val(); var p = $('#node-input-password').val(); + var k = $('#node-input-apikey').val(); - if (u && u.length && p) { + if ( (k && k.length) || (u && u.length && p) ) { if (!tor.models) { tor.checkModels(); if (tor.models) {$('#node-input-action').parent().show(); } @@ -366,7 +373,10 @@ $('select#node-input-destlang').empty(); available_destlang.forEach(function (val) { - var lang = val.split('-')[1]; + // can now have languages like zh-TW, so simple split on - + // no longer works. + // var lang = val.split('-')[1]; + let lang = val.replace(tor.srclang_selected + '-', ''); var selectedText = ''; if (tor.destlang_selected === lang) { @@ -559,6 +569,7 @@ var output_lang = tor.domain_type.map(function (a) { return a.target; }); + input_lang_unique = input_lang.filter(tor.checkUnique); output_lang_unique = output_lang.filter(tor.checkUnique); @@ -599,6 +610,12 @@ tor.checkActionSelected(); } }); + $('#node-input-apikey').change(function(val){ + tor.getCredentials(); + if (tor.have_credentials) { + tor.checkActionSelected(); + } + }); $('#node-input-action').change(function(val){ tor.action = $('#node-input-action').val(); @@ -727,6 +744,7 @@ srclang: {value: 'en'}, destlang: {value: 'fr'}, password: {value: ''}, + apikey: {value: ''}, custom: {value: ''}, domainhidden: {value: ''}, srclanghidden: {value: ''}, diff --git a/services/language_translator/v2.js b/services/language_translator/v3.js similarity index 91% rename from services/language_translator/v2.js rename to services/language_translator/v3.js index 2d85d125..469d733d 100644 --- a/services/language_translator/v2.js +++ b/services/language_translator/v3.js @@ -24,7 +24,7 @@ module.exports = function (RED) { // the edited ones are not being taken. const SERVICE_IDENTIFIER = 'language-translator'; var pkg = require('../../package.json'), - LanguageTranslatorV2 = require('watson-developer-cloud/language-translator/v2'), + LanguageTranslatorV3 = require('watson-developer-cloud/language-translator/v3'), //cfenv = require('cfenv'), payloadutils = require('../../utilities/payload-utils'), serviceutils = require('../../utilities/service-utils'), @@ -34,6 +34,8 @@ module.exports = function (RED) { password = null, sUsername = null, sPassword = null, + apikey = null, + sApikey = null, //service = cfenv.getAppEnv().getServiceCreds(/language translator/i), service = serviceutils.getServiceCreds(SERVICE_IDENTIFIER), endpoint = '', @@ -43,8 +45,9 @@ module.exports = function (RED) { temp.track(); if (service) { - sUsername = service.username; - sPassword = service.password; + sUsername = service.username ? service.username : ''; + sPassword = service.password ? service.password : ''; + sApikey = service.apikey ? service.apikey : ''; sEndpoint = service.url; } @@ -64,22 +67,27 @@ module.exports = function (RED) { var lt = null, neural = req.query.n ? true : false, serviceSettings = { - username: sUsername ? sUsername : req.query.un, - password: sPassword ? sPassword : req.query.pwd, - version: 'v2', + version: '2018-05-01', url: endpoint, headers: { 'User-Agent': pkg.name + '-' + pkg.version } }; + if (sApikey || req.query.key) { + serviceSettings.iam_apikey = sApikey ? sApikey : req.query.key; + } else { + serviceSettings.username = sUsername ? sUsername : req.query.un; + serviceSettings.password = sPassword ? sPassword : req.query.pwd; + } + if (neural) { serviceSettings.headers['X-Watson-Technology-Preview'] = '2017-07-01'; } - lt = new LanguageTranslatorV2(serviceSettings); + lt = new LanguageTranslatorV3(serviceSettings); - lt.getModels({}, function (err, models) { + lt.listModels({}, function (err, models) { if (err) { res.json(err); } @@ -105,8 +113,8 @@ module.exports = function (RED) { var node = this; - function initialCheck(u, p) { - if (!u || !p) { + function initialCheck(u, p, k) { + if (!k && (!u || !p)) { return Promise.reject('Missing Watson Language Translator service credentials'); } return Promise.resolve(); @@ -188,7 +196,7 @@ module.exports = function (RED) { } model_id = srclang + '-' + destlang; - if (domain !== 'news') { + if (domain !== 'news' && domain !== 'general') { model_id += ('-' + domain); } return Promise.resolve(model_id); @@ -374,14 +382,19 @@ module.exports = function (RED) { var p = null, language_translator = null, serviceSettings = { - username: username, - password: password, - version: 'v2', + version: '2018-05-01', headers: { 'User-Agent': pkg.name + '-' + pkg.version } }; + if (apikey) { + serviceSettings.iam_apikey = apikey; + } else { + serviceSettings.username = username; + serviceSettings.password = password; + } + if (endpoint) { serviceSettings.url = endpoint; } @@ -390,7 +403,7 @@ module.exports = function (RED) { serviceSettings.headers['X-Watson-Technology-Preview'] = '2017-07-01'; } - language_translator = new LanguageTranslatorV2(serviceSettings); + language_translator = new LanguageTranslatorV3(serviceSettings); // We have credentials, and know the mode. Further required fields checks // are specific to the requested action. @@ -434,6 +447,7 @@ module.exports = function (RED) { // Credentials are needed for each of the modes. username = sUsername || this.credentials.username; password = sPassword || this.credentials.password || config.password; + apikey = sApikey || this.credentials.apikey || config.apikey; endpoint = sEndpoint; if ((!config['default-endpoint']) && config['service-endpoint']) { @@ -442,7 +456,7 @@ module.exports = function (RED) { node.status({}); - initialCheck(username, password) + initialCheck(username, password, apikey) .then(function(){ return payloadCheck(msg); }) @@ -472,7 +486,8 @@ module.exports = function (RED) { RED.nodes.registerType('watson-translator', SMTNode, { credentials: { username: {type:'text'}, - password: {type:'password'} + password: {type:'password'}, + apikey: {type:'password'} } }); }; diff --git a/services/language_translator_identify/v2.html b/services/language_translator_identify/v3.html similarity index 92% rename from services/language_translator_identify/v2.html rename to services/language_translator_identify/v3.html index 0b62e5f2..03d62fe9 100644 --- a/services/language_translator_identify/v2.html +++ b/services/language_translator_identify/v3.html @@ -33,6 +33,10 @@
+
@@ -46,7 +50,7 @@