diff --git a/Gruntfile.js b/Gruntfile.js index 7767bc5..5c38c37 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -6,9 +6,13 @@ module.exports = function(grunt) { transifex: { "ios-ready": { options: { + project: "rrportal", targetDir: "./translations/ios-ready", // download specified resources / langs only resources: ["localizable_enstrings"], - languages: ["en", "fr", "en_US"] + skipResources: ["unusedproperties"], + languages: ["en", "fr", "en_US"], + skipLanguages: ["en"], + useSlug: false // instead of using tx slug, try to use the orignal uploaded file for resource } }, "new-admintool": { diff --git a/README.md b/README.md index 30ef01e..64409a9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,16 @@ # grunt-transifex -Provides a Grunt task that downloads translation strings from Transifex into your project using the [Transifex API](http://support.transifex.com/customer/portal/topics/440186-api/articles). +Provides a Grunt task that uploads new & existing source files to Transifex and downloads translation strings from Transifex into your project using the [Transifex API](http://support.transifex.com/customer/portal/topics/440186-api/articles). + +This fork adds uploading and a couple of config options to the original grunt plugin by erasys +Uploading (transifex-upload): +* uploading of new files +* uploading/overwriting of existing files + +Downloading (transifex): +* skipResources: if you want all expect a couple of resources +* skipLanguages: if the list of languages you don't want is shorter than the list you do want +* useSlug: The original plugin uses the slug for name, which loses capitalization. For example, if the uploaded files was uploadedFile.properties, the japanese download could be uploadedfile_ja.properties. Setting useSlug to false is able to spit out uploadedFile_ja.properties. ## Usage @@ -10,9 +20,13 @@ Provides a Grunt task that downloads translation strings from Transifex into you transifex: { "ios-ready": { options: { + project: "rrportal" // your transifect project targetDir: "./translations/ios-ready", // download specified resources / langs only resources: ["localizable_enstrings"], + skipResources: ["unusedproperties"], // useful for "all resources except these couple". In "slug" format languages: ["en_US", "fr"], + skipLanguages: ["en"], // useful for "all languages but english" + useSlug: false, // instead of using tx slug, try to use the orignal uploaded file for resource filename : "_resource_-_lang_.json", templateFn: function(strings) { return ...; } // customize the output file format (see below) } @@ -37,7 +51,7 @@ This configuration enables running the `transifex` Grunt task on the command lin 'en_US' and 'fr' grunt transifex:ios-ready:reviewed --> Same as above, but downloads reviewed strings only - + grunt transifex --> Downloads reviewed & non-reviewed strings for all configured Transifex projects grunt transifex::reviewed @@ -56,7 +70,7 @@ Translated strings will saved into plain JSON if you use the default output conf ## Transifex credentials When the plugin runs for the first time, it will prompt the user for a Transifex username and password. -It will store this information in a `.transifexrc` file created in the current directory. +It will store this information in a `.transifexrc` file created in the current directory. On subsequent executions, the user won't be prompted again. Transifex credentials will be read from `.transifexrc` diff --git a/lib/transifex-api.js b/lib/transifex-api.js index e3d432a..aa5b407 100644 --- a/lib/transifex-api.js +++ b/lib/transifex-api.js @@ -1,19 +1,18 @@ - var request = require('request'), - grunt = require('grunt'), - credentials = require('./credentials'), - async = require('async'), - _ = require('underscore'), - path = require('path'); + grunt = require('grunt'), + credentials = require('./credentials'), + async = require('async'), + _ = require('underscore'), + path = require('path'); var Api = module.exports = function(options) { this.options = options; _.bindAll(this, "availableResources", "resourceDetails", - "prepareRequests", "fetchStrings", "writeLanguageFiles"); + "prepareRequests", "fetchStrings", "writeLanguageFiles", "uploadLanguageFiles"); }; -Api.prototype.request = function(/*...elements*/) { +Api.prototype.request = function( /*...elements*/ ) { return { url: [this.options.endpoint].concat(Array.prototype.splice.call(arguments, 0)).join('/'), auth: this.options.credentials, @@ -28,20 +27,20 @@ Api.prototype.availableResources = function(callback) { var self = this; request(this.request("project", this.options.project, "resources"), - function(err, response, body) { - /* handle errors */ - if (err) return callback(err); - if (response.statusCode === 401) { - return credentials.delete(function() { - callback(new Error("Invalid Transifex crendentials. Aborting.")); - }); - } - if (response.statusCode === 404) { - return callback(new Error("Project slug " + self.options.project + " not found. Aborting.")); - } + function(err, response, body) { + /* handle errors */ + if (err) return callback(err); + if (response.statusCode === 401) { + return credentials.delete(function() { + callback(new Error("Invalid Transifex crendentials. Aborting.")); + }); + } + if (response.statusCode === 404) { + return callback(new Error("Project slug " + self.options.project + " not found. Aborting.")); + } - return callback(null, _.pluck(body, "slug")); - }); + return callback(null, body); + }); }; /* Map each resource slug fetched above to a list @@ -50,16 +49,19 @@ Api.prototype.resourceDetails = function(resources, callback) { var self = this; async.map(resources, function(resource, step) { - request(self.request("project", self.options.project, "resource", resource+"?details"), - function(err, response, body) { step(err, body); }); + request(self.request("project", self.options.project, "resource", resource.slug + "?details"), + function(err, response, body) { + step(err, body); + }); }, function(err, details) { var codes = details.map(function(d) { return { source: d.source_language_code, - codes: _.pluck(d.available_languages, "code") + codes: _.difference(_.pluck(d.available_languages, "code"), self.options.skipLanguages), + name: d.name }; }); - callback(err, _.object(resources, codes)); + callback(err, _.object(_.pluck(resources, 'slug'), codes)); }); }; @@ -70,6 +72,11 @@ Api.prototype.prepareRequests = function(availableResources, callback) { var self = this; var resources = this.options.resources === "*" ? Object.keys(availableResources) : this.options.resources; + + if (_.isArray(this.options.skipResources)) { + resources = _.difference(resources, this.options.skipResources); + } + callback(null, resources.reduce(function(requests, slug) { if (typeof availableResources[slug] === "undefined") { grunt.log.warn("Resource", slug, "not found. Skipping."); @@ -83,18 +90,19 @@ Api.prototype.prepareRequests = function(availableResources, callback) { var uri = ""; - if( self.options.mode === "file" ){ + if (self.options.mode === "file") { uri = ["project", self.options.project, "resource", slug, "translation", code].join('/').concat('?file=true'); } else { uri = ["project", self.options.project, "resource", slug, "translation", code, "strings"].join('/'); } - requests.push( { + requests.push({ uri: uri, slug: slug, code: code, + name: availableResources[slug].name, isSource: code === availableResources[slug].source - } ); + }); }); } @@ -113,7 +121,9 @@ Api.prototype.fetchStrings = function(requests, callback) { async.map(requests, function(req, step) { request(self.request(req.uri), function(err, response, body) { delete req.uri; - req.strings = (!self.options.reviewed || req.isSource) ? body : body.filter(function(s) {return s.reviewed;}); + req.strings = (!self.options.reviewed || req.isSource) ? body : body.filter(function(s) { + return s.reviewed; + }); step(err, req); }); }, callback); @@ -127,9 +137,20 @@ Api.prototype.writeLanguageFiles = function(strings, callback) { strings.forEach(function(s) { var pathParts = self.options.filename.split('/'), - filename = pathParts.pop(), - targetDir = path.join.apply(path, [self.options.targetDir].concat(pathParts)).replace('_lang_', s.code).replace('_resource_', s.slug), - filepath = path.join(targetDir, filename).replace('_lang_', s.code).replace('_resource_', s.slug); + filename = pathParts.pop(), + originalResourceName = s.name.replace(/\.[^/.]+$/, "").replace(/\_[^/_]+$/, ""), // remove extension and language code + targetDir = path.join.apply(path, [self.options.targetDir].concat(pathParts)).replace('_lang_', s.code), + filepath = path.join(targetDir, filename).replace('_lang_', s.code); + + // Using slug loses case + if (self.options.useSlug) { + targetDir = targetDir.replace('_resource_', s.slug); + filepath = filepath.replace('_resource_', s.slug); + } else { + targetDir = targetDir.replace('_resource_', originalResourceName); + filepath = filepath.replace('_resource_', originalResourceName); + } + // Make sure that the resource target directory exists grunt.file.mkdir(targetDir); @@ -137,10 +158,12 @@ Api.prototype.writeLanguageFiles = function(strings, callback) { // write file // discard keys with empty translations var transformed = ''; - if( self.options.mode === "file" ){ + if (self.options.mode === "file") { transformed = s.strings; } else { - transformed = self.options.templateFn(s.strings.filter(function(s) { return s.translation !== ""; })); + transformed = self.options.templateFn(s.strings.filter(function(s) { + return s.translation !== ""; + })); } grunt.file.write(filepath, transformed); @@ -149,3 +172,180 @@ Api.prototype.writeLanguageFiles = function(strings, callback) { callback(); }; + +/* + * Upload all files, figure out which are existing and which aren't, divert to the right apis + * */ +Api.prototype.uploadLanguageFiles = function(existingResources, callback) { + var i, l, j, m, + self = this, + files = [], + existingFiles = [], + newFiles = [], + colorOptions; + + grunt.verbose.writeln('Hitting uploadLanguageFiles with existingResources: ', existingResources); + // Gather up files to be uploaded/updated + if (this.options.resources === "*") { + // grab all form resourceDir + files = []; + grunt.file.recurse(self.options.resourceDir, function(abspath, rootdir, subdir, filename) { + if (filename.indexOf(self.options.extension) > -1) { + files.push(filename); + } else { + grunt.verbose.writeln('Rejecting this file for not having the right extension: ', filename); + } + }); + } else { + // use the ones passed in + files = this.options.resources; + } + grunt.verbose.writeln('Files were considering', files); + + // we only want to upload source language files + files = _.reject(files, function(name) { + var match = name.match(/_[a-z]{2}/); + return _.isArray(match) ? (match[0] !== '_' + self.options.sourceLanguage) : false; + }); + grunt.verbose.writeln('Files after kicking out translations', files); + + // split in existingFiles & newFiles + for (i = 0, l = files.length; i < l; i++) { + var file = files[i]; + var found = false; + for (j = 0, m = existingResources.length; j < m; j++) { + var existingResource = existingResources[j]; + + if (file === existingResource.name) { + existingFiles.push(existingResource); + found = true; + break; + } + } + + if (!found) { + newFiles.push(file); + } + } + + colorOptions = { + // The separator string (can be colored). + separator: ', ', + // The array item color (specify false to not colorize). + color: 'red' + }; + + // confirm this is what we want as uploading can be destructive + grunt.log.writeln(''); + grunt.log.writeln('---------------------------------------------'); + grunt.log.writeln('Source files to be overwritten on transifex: ', grunt.log.wordlist(_.pluck(existingFiles, 'name'), colorOptions)); + grunt.log.writeln('New source files to be uploaded to transifex: ', grunt.log.wordlist(newFiles, colorOptions)); + grunt.log.writeln('---------------------------------------------'); + grunt.log.writeln(''); + + + self.uploadExistingLanguageFiles(existingFiles); + self.uploadNewLanguageFiles(newFiles); + + callback(); +}; + +/* + * Upload new files to transifex + * */ +Api.prototype.uploadNewLanguageFiles = function(files) { + var i, l, self = this, + resource, + contents, + options, + fileName; + + for (i = 0, l = files.length; i < l; i++) { + fileName = files[i]; + resource = self.options.resourceDir + '/' + fileName; + grunt.verbose.writeln('setting up uploading of new file ', resource); + + contents = grunt.file.read(resource); + + options = { + url: self.options.endpoint + '/project/' + self.options.project + '/resources/', + auth: self.options.credentials, + header: { + "Content-Type": "application/json" + }, + json: { + name: fileName, + slug: fileName.substring(0, fileName.lastIndexOf('.')), // use filename as new slug + content: contents, + i18n_type: self.options.type + } + }; + grunt.log.writeln('Uploading new file ', fileName); + + request.post(options, function(error, response, body) { + grunt.verbose.writeln('upload error: ', error); +// grunt.verbose.writeln('upload response: ', response); + grunt.verbose.writeln('upload statusCode: ', response.statusCode); + grunt.verbose.writeln('upload body: ', body); + + if (!error && response.statusCode === 201) { + grunt.log.writeln('Successfully uploaded to ' + response.req.path, 'Status: ' + response.statusMessage); + } else { + grunt.log.writeln('Failed to upload ', 'Status: ' + response.statusMessage, 'Error: ', error); + } + }); + + } +}; + + +/* + * Upload updated existing files + * */ +Api.prototype.uploadExistingLanguageFiles = function(files) { + var i, l, self = this, + resource, + fileName, + slug, + contents, + options, + url; + + for (i = 0, l = files.length; i < l; i++) { + fileName = files[i].name; + slug = files[i].slug; + resource = self.options.resourceDir + '/' + fileName; + url = self.options.endpoint + '/project/' + self.options.project + '/resource/' + slug + '/content'; + grunt.verbose.writeln('setting up uploading of existing file ', resource, ' using slug name ' + slug); + + contents = grunt.file.read(resource); + + options = { + url: url, + auth: self.options.credentials, + header: { + "Content-Type": "application/json" + }, + json: { + content: contents, + i18n_type: self.options.type + } + }; + grunt.log.writeln('Uploading existing file ', fileName); + + + request.put(options, function(error, response, body) { + grunt.verbose.writeln('upload error: ', error); +// grunt.verbose.writeln('upload response: ', response); + grunt.verbose.writeln('upload body: ', body); + grunt.verbose.writeln('upload response statusCode: ', response.statusCode); + + if (!error && response.statusCode === 200) { + grunt.log.writeln('Successfully uploaded to ' + response.req.path, 'Status: ' + response.statusMessage); + } else { + grunt.log.writeln('Failed to upload ', 'Status: ' + response.statusMessage, 'Error: ', error); + } + }); + + } +}; diff --git a/tasks/transifex.js b/tasks/transifex.js index bd3d1fc..166ac71 100644 --- a/tasks/transifex.js +++ b/tasks/transifex.js @@ -1,8 +1,7 @@ - var async = require('async'), - _ = require('underscore'), - credentials = require('../lib/credentials'), - Transifex = require('../lib/transifex-api'); + _ = require('underscore'), + credentials = require('../lib/credentials'), + Transifex = require('../lib/transifex-api'); module.exports = function(grunt) { grunt.registerMultiTask("transifex", "Grunt task that downloads string translations from Transifex", function() { @@ -11,13 +10,18 @@ module.exports = function(grunt) { /* Extend given options with some defaults */ this.options = this.options({ resources: '*', + skipResources: [], languages: '*', - endpoint : 'http://www.transifex.com/api/2', - project : this.target, - reviewed : this.flags.reviewed, + skipLanguages: [], + endpoint: 'http://www.transifex.com/api/2', + project: this.target, + reviewed: this.flags.reviewed, mode: "json", - filename : "_resource_/_lang_.json", - templateFn: function(strings) { return JSON.stringify(_.object(_.pluck(strings, "key"), _.pluck(strings, "translation"))); } + useSlug: true, + filename: "_resource_/_lang_.json", + templateFn: function(strings) { + return JSON.stringify(_.object(_.pluck(strings, "key"), _.pluck(strings, "translation"))); + } }); /** Attempt to create target directory @@ -43,4 +47,37 @@ module.exports = function(grunt) { ], done); }); }); + + grunt.registerMultiTask("transifex-upload", "Grunt task that uploads new and existing translation files to Transifex", function() { + var self = this; + grunt.log.writeln('hits task ok') + /* Extend given options with some defaults */ + this.options = this.options({ + resources: '*', + sourceLanguage: '', // assumes no _en extension for source. use "en" to match with a specific extension + endpoint: 'http://www.transifex.com/api/2', + project: this.target, + reviewed: this.flags.reviewed, + mode: "json", + type: "UNICODEPROPERTIES", + extension: 'properties' + }); + + if (!this.options.resourceDir) { + grunt.fatal("Please provide 'resourceDir' option"); + } + + /** Ensure we find some Transifex credentials + * then do the main work */ + var done = this.async(); + credentials.read(function(err, creds) { + self.options.credentials = creds; + var api = new Transifex(self.options); + + async.waterfall([ + api.availableResources, + api.uploadLanguageFiles + ], done); + }); + }); };