UNPKG

get-translation

Version:
494 lines (436 loc) 13 kB
/** * Module dependencies. */ var fs = require('fs') , parser = require('./parser') , syntax = require('./syntax') , _ = require('underscore') , merger = require('./merger') , readline = require('readline') , file = require('./file') , Hashids = require('hashids') , hashids = new Hashids(pcf.TRANSLATION_ID_HASH_SECRET, pcf.TRANSLATION_ID_CHAR_LENGTH); /** * Add terminal colors */ require('terminal-colors'); /** * Update * * @constructor */ function Update() { this.functionCallRegex = lcf.TRANSLATION_FUNCTION_CALL_REGEX; this.isWaitingUserInput = false; this.deletedKeys = []; this.addedKeys = []; this.migratedKeys = []; // readline interface this.rl; // file sources this.src = pcf.src; // locales this.locales = pcf.locales; // default locale this.defaultLocale = pcf.defaultLocale; // locales folder this.localesFolder = pcf.localesFolder; // new line this.newline = '\n'; } /** * Update keys * * @return {void} * @api public */ Update.prototype.update = function() { var newTranslations = this._getSourceKeys(); this._mergeTranslations(newTranslations, function(err, _newTranslations) { if(!err) { return file.writeTranslations(_newTranslations); } console.error('Translation update failed'); }); }; /** * Strip inner functions calls. All function calls that is not gt() * needs to be removed. Because they can cause updating error * when they are defined inside vars like below * * Example: * * gt('SOME_TRANSLATION_KEY', { * prop1 : test() // Becomes '' * }); * * @param {string} content, file content * @api private */ Update.prototype._stripInnerFunctionCalls = function(content) { return content.replace(lcf.TRANSLATION_INNER_FUNCTION_CALL_REGEX, function(m) { if(/gt\(/g.test(m)) { return m; } else { return ''; } }); }; /** * Get translation function calls form source * * @return {Object} newTranslations * @api private */ Update.prototype._getSourceKeys = function() { var now = parseInt(Date.now() / 1000, 10) , _this = this, newTranslations = {} , counter = 0; this.src.forEach(function(file) { if(fs.lstatSync(file).isDirectory()) { return; } var content = _this._stripInnerFunctionCalls(fs.readFileSync(file, 'utf8')); // Match all gt() calls var calls = content.match(_this.functionCallRegex); if(calls !== null) { calls.forEach(function(call) { var key = parser.getKey(call) , vars = parser.getVars(call); if(!(key in newTranslations)) { newTranslations[key] = {}; newTranslations[key].id = hashids.encrypt(now, counter); newTranslations[key].key = key; newTranslations[key].vars = vars; newTranslations[key].text = key; newTranslations[key].files = [file]; counter++; } else { if(syntax.hasErrorDuplicate(newTranslations, key, vars)) { throw new TypeError('You have defined a translation key (' + key + ') with different vars.\n In file:' + file); } newTranslations[key].files.push(file); } }); } }); return newTranslations; }; /** * Merge new translations with old translations * * @param {Object} newTranslations * @param {function} callback * @return newTranslations {Object} * @api private */ Update.prototype._mergeTranslations = function(newTranslations, callback) { var oldTranslations = file.readTranslations() , _newTranslations = {} , now = Date.now(); for(var locale in this.locales) { _newTranslations[locale] = JSON.parse(JSON.stringify(newTranslations)); for(var key in _newTranslations[locale]) { if(typeof oldTranslations[locale] !== 'undefined' && typeof oldTranslations[locale][key] !== 'undefined') { var _new = _newTranslations[locale] , old = oldTranslations[locale]; // Assign translation _newTranslations[locale] = merger.mergeTranslations(_new, old, key); // Set timestamp _newTranslations[locale] = merger.mergeTimeStamp(_new, old, key); // Assign id _newTranslations[locale] = merger.mergeId(_new, old, key); } else { _newTranslations[locale][key].values = []; _newTranslations[locale][key].timestamp = now; if(locale === pcf.defaultLocale) { console.log('[added]'.green + ' ' + key); } } } } this._mergeUserInputs(_newTranslations, oldTranslations, function(err, _newTranslations) { if(!err) { return callback(null, _newTranslations); } if(err.error === 'SIGINT') { return callback(null, oldTranslations); } callback(err); }); }; /** * Get deleted translations. This method returns deleted translations * by looking at the source updated translations (newTranslations) * and the current stored translations (oldTranslations) * * @param {Object} newTranslations * @param {Object} oldTranslations * @return deletedTranslations {Object} * * Returns: * { * TRANSLATION_KEY1 : { * LOCALE1 : { * * }, * LOCALE2 : { * * }, * ... * timestamp : TIMESTAMP, * files : [FILE1, FILE2, ...] * }, * TRANSLATION_KEY1 : { * LOCALE1 : { * * }, * LOCALE2 : { * * }, * ... * timestamp : TIMESTAMP, * files : [FILE1, FILE2, ...] * }, * ... * } * * @api private */ Update.prototype._getDeletedTranslations = function(newTranslations, oldTranslations) { var now = Date.now(), deletedTranslations = {}; for(var locale in this.locales) { for(var key in oldTranslations[locale]) { if(!(key in newTranslations[locale])) { if(!(key in deletedTranslations)){ deletedTranslations[key] = {}; } if('values' in oldTranslations[locale][key]) { deletedTranslations[key][locale] = oldTranslations[locale][key]; } else { deletedTranslations[key][locale] = []; } deletedTranslations[key].timestamp = now; deletedTranslations[key].files = oldTranslations[locale][key].files; } } } return deletedTranslations; }; /** * Get newly added translation key files. If a key have been added * to source that never been added before. You can get * the path to the file/files where the key is used. This method * is useful during translation key update. Because we can * check which other keys is existing in that newly added key's file. * So smart updating of keys without losing stored values can be achieved. * * @param {Object} newTranslations * @param {Object} oldTranslations * @return {Object} files * * Returns: * { * 'FILE1' : [TRANSLATION_KEY1, TRANSLATION_KEY2, ...], * 'FILE2' : [TRANSLATION_KEY1, TRANSLATION_KEY2, ...], * ... * } * * @api private */ Update.prototype._getUpdatedFiles = function(newTranslations, oldTranslations) { var files = {}; for(var key in newTranslations[this.defaultLocale]) { if(!(key in oldTranslations[this.defaultLocale])) { var translationFiles = newTranslations[this.defaultLocale][key].files; for(var file in translationFiles) { if(!(translationFiles[file] in files)) { files[translationFiles[file]] = [key]; } else if(!_.contains(files[translationFiles[file]], key)) { files[translationFiles[file]].push(key); } } } } return files; }; /** * Merge user inputs. * * @param {Object} newTranslations * @param {Object} oldTranslations * @param {Function} callback * @return {void} * @api private */ Update.prototype._mergeUserInputs = function(newTranslations, oldTranslations, callback) { var deletedTranslations = this._getDeletedTranslations(newTranslations, oldTranslations); if(_.size(deletedTranslations) === 0) { return callback(null, newTranslations); } var updatedFiles = this._getUpdatedFiles(newTranslations, oldTranslations); // Push to user input stream for(var key in deletedTranslations) { for(var file in deletedTranslations[key].files) { if(!_.has(updatedFiles, deletedTranslations[key].files[file])) { continue; } // Add deleted key and added keys for the file this._pushToUserInputStream(key, updatedFiles[deletedTranslations[key].files[file]]); } } this._executeUserInputStream(newTranslations, oldTranslations, function(err, _newTranslations) { if(!err) { return callback(null, _newTranslations); } callback(err); }); }; /** * Push to user input stream. addedKeys are keys added for a specific files * that is in the same file as deletedKey. * * @param {String} deletedKey * @param {Array} addedKeys * @return {void} * @api private */ Update.prototype._pushToUserInputStream = function(deletedKey, addedKeys) { if(typeof deletedKey !== 'string') { throw new TypeError('First parameter must be a string'); } if(!_.isArray(addedKeys)) { throw new TypeError('Second parameter must be an array, containing translation keys'); } this.deletedKeys.push(deletedKey); this.addedKeys.push(addedKeys); }; /** * Execute user input stream * * @param {Object} newTranslations * @param {Object} oldTranslations * @param {Function} callback * @return {void} * @api private */ Update.prototype._executeUserInputStream = function(newTranslations, oldTranslations, callback) { var _this = this; if(this.deletedKeys.length === 0 || this.addedKeys.length === 0) { return callback(null, newTranslations); } // Error handling if(this.deletedKeys.length !== this.addedKeys.length) { throw new TypeError('Deleted keys must have same array length as added keys length'); } var hasError = false; function recurse() { var deletedKey = _this.deletedKeys.shift(); var addedKeys = _.difference(_this.addedKeys.shift(), _this.migratedKeys); _this._getUserInputKey(deletedKey, addedKeys, function(err, newKey, oldKey) { if(err) { hasError = true; return callback(err); } if(newKey === 'DELETE' && _this.deletedKeys.length !== 0) { return recurse(); } else if(newKey === 'DELETE') { if(_this.rl) _this.rl.close(); return callback(null, newTranslations); } newTranslations = _this._setOldTranslation(newKey, oldKey, newTranslations, oldTranslations); if(_this.deletedKeys.length !== 0) { return recurse(); } else { _this.rl.close(); callback(null, newTranslations); } }); } recurse(); }; /** * Set old translation on the new translation object on a specfic key * * @param {String} newKey * @param {String} oldKey * @param {Object} newTranslation * @param {Object} oldTranslation * @return {Object} newTranslation * @api private */ Update.prototype._setOldTranslation = function(newKey, oldKey, newTranslations, oldTranslations) { for(var locale in this.locales) { newTranslations[locale][newKey] = oldTranslations[locale][oldKey]; newTranslations[locale][newKey].key = newKey; } return newTranslations; }; /** * Get user input update actions. * * @param {String} deletedKey * @param {Array.<String>} addedKeys * @param {Function(err, migrationKey, deletedKey)} callback * @return {void} * @api private */ Update.prototype._getUserInputKey = function(deletedKey, addedKeys, callback) { var _this = this; if(!addedKeys.length) { return callback(null, 'DELETE'); } if(!this.rl) { this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal : false }); } var n = 1, _addedKeys = []; var question = 'The key "' + deletedKey + '" is now gone in source.\n' + 'What do you want to do with it?\n'; var options = '[', optionsEndWrap = 'd]'; for(var key in addedKeys) { question += ('[' + n + ']').lightBlue + ' - migrate to "' + addedKeys[key].yellow + '"\n'; _addedKeys.push(addedKeys[key]); options += n + ','; n++; } // Add delete option question += '[d]'.lightBlue + ' - ' + 'delete'.red + '\n'; question += (options + optionsEndWrap).lightBlue + ' (d)? '; this.rl.question(question, function(option) { if(/^\d+$/.test(option) && +option <= _addedKeys.length && +option > 0) { var migrationKey = _addedKeys[option - 1]; _this.migratedKeys.push(migrationKey); callback(null, migrationKey, deletedKey); } else { callback(null, 'DELETE'); } }); this.rl.on('SIGINT', function() { callback({ error: 'SIGINT'}); _this.rl.close(); }); }; /** * Export instance */ module.exports = new Update(); /** * Export constructor */ module.exports.Update = Update;