OLD | NEW |
(Empty) | |
| 1 const fs = require("fs"); |
| 2 const {exec} = require("child_process"); |
| 3 |
| 4 const localesDir = "locale"; |
| 5 const defaultLocale = "en_US"; |
| 6 |
| 7 let filesNames = []; // ex.: desktop-options.json |
| 8 let locales = []; // List of all available locale codes |
| 9 let headers = ["StringID", "Description", "Placeholders", defaultLocale]; |
| 10 let outputFileName = "translations-{repo}-{hash}.csv"; |
| 11 |
| 12 /** |
| 13 * Export existing translation files into CSV file |
| 14 * @param {[type]} filesFilter Optional parameter which allow include only |
| 15 * fileNames in the array, if ommited all files |
| 16 * will be exported |
| 17 */ |
| 18 function exportTranslations(filesFilter) |
| 19 { |
| 20 let mercurialCommands = []; |
| 21 mercurialCommands.push(executeMercurial("hg id -i")); // Get Hash |
| 22 mercurialCommands.push(executeMercurial("hg paths default")); // Get repo path |
| 23 Promise.all(mercurialCommands).then((outputs) => |
| 24 { |
| 25 // Remove line endings and "+" sign from the end of the hash |
| 26 let [hash, path] = outputs.map((item) => item.replace(/\+\n|\n$/, "")); |
| 27 // Update name of the file to be outputted |
| 28 outputFileName = outputFileName.replace("{hash}", hash); |
| 29 outputFileName = outputFileName.replace("{repo}", path.split("/").pop()); |
| 30 |
| 31 // Prepare to read all available locales and default files |
| 32 let readDirectories = []; |
| 33 readDirectories.push(readDir(`${localesDir}/${defaultLocale}`)); |
| 34 readDirectories.push(readDir(localesDir)); |
| 35 return Promise.all(readDirectories); |
| 36 }).then((files) => |
| 37 { |
| 38 [filesNames, locales] = files; |
| 39 // Filter files |
| 40 if (filesFilter.length) |
| 41 filesNames = filesNames.filter((item) => filesFilter.includes(item)); |
| 42 |
| 43 let readJsonPromises = []; |
| 44 for(let file of filesNames) |
| 45 for(let locale of locales) |
| 46 readJsonPromises.push(readJson(locale, file)); |
| 47 |
| 48 // Reading all existing translations files |
| 49 return Promise.all(readJsonPromises); |
| 50 }).then((fileObjects) => |
| 51 { |
| 52 // Create Object tree from the Objects array, for easier search |
| 53 // ex.: {dektop-options.json: {en_US: {...}, {de: {...}, {ru: {...}}} |
| 54 let dataTreeObj = fileObjects.reduce((acc, fileObject) => |
| 55 { |
| 56 if (!fileObject) |
| 57 return acc; |
| 58 |
| 59 let filename = fileObject.filename; |
| 60 let locale = fileObject.locale; |
| 61 if (!acc[filename]) |
| 62 { |
| 63 acc[filename] = {}; |
| 64 } |
| 65 acc[filename][locale] = fileObject.strings; |
| 66 return acc; |
| 67 }, {}); |
| 68 |
| 69 // Create two dimentional strings array that reflects CSV structure |
| 70 let localesWithoutDefault = locales.filter((item) => item != defaultLocale); |
| 71 let csvArray = [headers.concat(localesWithoutDefault)]; |
| 72 for (let file of filesNames) |
| 73 { |
| 74 csvArray.push([file]); |
| 75 for (let stringID in dataTreeObj[file][defaultLocale]) |
| 76 { |
| 77 let fileObj = dataTreeObj[file]; |
| 78 let stringObj = fileObj[defaultLocale][stringID]; |
| 79 let {description, message, placeholders} = stringObj; |
| 80 |
| 81 // Use yaml-like format for easy extraction, rather sensitive char hacks |
| 82 let yamlPlaceholder = ""; |
| 83 for (let placeholder in placeholders) |
| 84 { |
| 85 yamlPlaceholder += `${placeholder}:\n`; |
| 86 let {content, example} = placeholders[placeholder]; |
| 87 yamlPlaceholder += ` content: ${content}\n`; |
| 88 yamlPlaceholder += ` example: ${example}\n`; |
| 89 } |
| 90 |
| 91 let row = [stringID, description || "", yamlPlaceholder, message]; |
| 92 for (let locale of localesWithoutDefault) |
| 93 { |
| 94 let localeFileObj = fileObj[locale]; |
| 95 let isTranslated = localeFileObj && localeFileObj[stringID]; |
| 96 row.push(isTranslated ? localeFileObj[stringID].message : ""); |
| 97 } |
| 98 csvArray.push(row); |
| 99 } |
| 100 } |
| 101 arrayToCsv(csvArray); // Convert matrix to CSV |
| 102 }); |
| 103 } |
| 104 |
| 105 /** |
| 106 * Import strings from the CSV file |
| 107 * @param {[type]} filePath CSV file path to import from |
| 108 */ |
| 109 function importTranslations(filePath) |
| 110 { |
| 111 readCsv(filePath).then((fileObjects) => |
| 112 { |
| 113 let dataMatrix = csvToArray(fileObjects); |
| 114 let headers = dataMatrix.splice(0, 1)[0]; |
| 115 let dataTreeObj = {}; |
| 116 let currentFilename = ""; |
| 117 for(let rowId in dataMatrix) |
| 118 { |
| 119 let row = dataMatrix[rowId]; |
| 120 let [stringId, description, placeholder] = row; |
| 121 if (!stringId) |
| 122 continue; |
| 123 |
| 124 stringId = stringId.trim(); |
| 125 if (stringId.endsWith(".json")) // Check if it's the filename row |
| 126 { |
| 127 currentFilename = stringId; |
| 128 dataTreeObj[currentFilename] = {}; |
| 129 continue; |
| 130 } |
| 131 |
| 132 description = description.trim(); |
| 133 placeholder = placeholder.trim(); |
| 134 for (let i = 3; i < headers.length; i++) |
| 135 { |
| 136 let locale = headers[i].trim(); |
| 137 let message = row[i].trim(); |
| 138 if (!message) |
| 139 continue; |
| 140 |
| 141 // Create Object tree from the Objects array, for easier search |
| 142 // ex.: {dektop-options.json: {en_US: {...}, {de: {...}, {ru: {...}}} |
| 143 if (!dataTreeObj[currentFilename][locale]) |
| 144 dataTreeObj[currentFilename][locale] = {}; |
| 145 |
| 146 let localeObj = dataTreeObj[currentFilename][locale]; |
| 147 localeObj[stringId] = {}; |
| 148 |
| 149 // We keep string descriptions only in default locale files |
| 150 if (locale == defaultLocale) |
| 151 localeObj[stringId].description = description; |
| 152 |
| 153 localeObj[stringId].message = message; |
| 154 if (placeholder) |
| 155 { |
| 156 let placeholders = placeholder.split("\n"); |
| 157 let placeholderName = ""; |
| 158 localeObj[stringId].placeholders = placeholders.reduce((acc, item) => |
| 159 { |
| 160 /* |
| 161 Placeholders use YAML like syntax in CSV files, ex: |
| 162 tracking: |
| 163 content: $1 |
| 164 example: Block additional tracking |
| 165 acceptableAds: |
| 166 content: $2 |
| 167 example: Allow Acceptable Ads |
| 168 */ |
| 169 if (item.startsWith(" ")) |
| 170 { |
| 171 let [key, value] = item.trim().split(":"); |
| 172 acc[placeholderName][key] = value.trim(); |
| 173 } |
| 174 else |
| 175 { |
| 176 placeholderName = item.trim().replace(":", ""); |
| 177 acc[placeholderName] = {}; |
| 178 } |
| 179 return acc; |
| 180 }, {}); |
| 181 } |
| 182 } |
| 183 } |
| 184 writeJson(dataTreeObj); |
| 185 }); |
| 186 } |
| 187 |
| 188 /** |
| 189 * Write locale files according to dataTreeObj which look like: |
| 190 * @param {Object} dataTreeObj which look like: |
| 191 * {dektop-options.json: {en_US: {...}, {de: {...}, {ru: {...}}} |
| 192 */ |
| 193 function writeJson(dataTreeObj) |
| 194 { |
| 195 for (let filename in dataTreeObj) |
| 196 { |
| 197 for (let locale in dataTreeObj[filename]) |
| 198 { |
| 199 let path = `${localesDir}/${locale}/${filename}`; |
| 200 let fileString = JSON.stringify(dataTreeObj[filename][locale], null, 2); |
| 201 fileString += "\n"; // Newline at end of file to match Coding Style |
| 202 fs.writeFile(path, fileString, 'utf8', (err)=> |
| 203 { |
| 204 if (!err) |
| 205 { |
| 206 console.log(`Updated: ${path}`); |
| 207 } |
| 208 else |
| 209 { |
| 210 console.log(err); |
| 211 } |
| 212 }); |
| 213 } |
| 214 } |
| 215 } |
| 216 |
| 217 /** |
| 218 * Parse CSV string and return array |
| 219 * @param {String} csvText Array to convert from |
| 220 * @return {Array} two dimentional array |
| 221 */ |
| 222 function csvToArray(csvText) |
| 223 { |
| 224 let previouseChar = ""; |
| 225 let row = []; // Holds parsed CSV data representing a row/line |
| 226 let column = 0; // Pointer of the column in the row |
| 227 let csvArray = []; // Two dimentional array that holds rows |
| 228 let parseSpecialChars = true; // Like comma(,) and quotation(") |
| 229 for (let charIndex in csvText) |
| 230 { |
| 231 currentChar = csvText[charIndex]; |
| 232 if (!row[column]) |
| 233 row[column] = ""; |
| 234 |
| 235 if ('"' == currentChar) |
| 236 { |
| 237 // Double quote is like escaping quote char in CSV |
| 238 if (currentChar === previouseChar && parseSpecialChars) |
| 239 row[column] += currentChar; |
| 240 |
| 241 parseSpecialChars = !parseSpecialChars; |
| 242 } |
| 243 else if (currentChar == "," && parseSpecialChars) |
| 244 { |
| 245 currentChar = ""; |
| 246 column++; // Update columns, because comma(,) separates columns |
| 247 } |
| 248 else if (currentChar == "\n" && parseSpecialChars) |
| 249 { |
| 250 if ("\r" === previouseChar) // In case of \r\n |
| 251 row[column] = row[column].slice(0, -1); |
| 252 |
| 253 csvArray.push(row); |
| 254 // Reset pointers for the new row |
| 255 row = []; |
| 256 column = 0; |
| 257 currentChar = ""; |
| 258 } |
| 259 else |
| 260 { |
| 261 row[column] += currentChar; |
| 262 } |
| 263 previouseChar = currentChar; |
| 264 } |
| 265 csvArray.push(row); |
| 266 return csvArray; |
| 267 } |
| 268 |
| 269 |
| 270 /** |
| 271 * Convert two dimentional array to the CSV file |
| 272 * @param {Array} csvArray Array to convert from |
| 273 */ |
| 274 function arrayToCsv(csvArray) |
| 275 { |
| 276 let dataToWrite = ""; |
| 277 for (let row of csvArray) |
| 278 { |
| 279 let columnString = row.reduce((accum, col) => |
| 280 { |
| 281 // Escape single quote with quote before |
| 282 accum += `","${col.replace(/\"/g, '""')}`; |
| 283 return accum; |
| 284 }); |
| 285 dataToWrite += `"${columnString}"\r\n`; |
| 286 } |
| 287 dataToWrite += "\r\n"; |
| 288 fs.writeFile(outputFileName, dataToWrite, "utf8", function (err) |
| 289 { |
| 290 if (!err) |
| 291 console.log(`${outputFileName} is created`); |
| 292 }); |
| 293 } |
| 294 |
| 295 /** |
| 296 * Reads JSON file and assign filename and locale to it |
| 297 * @param {String} locale ex.: "en_US", "de"... |
| 298 * @param {String} fileName ex.: "desktop-options.json" |
| 299 * @return {Promise} Promise object |
| 300 */ |
| 301 function readJson(locale, file) |
| 302 { |
| 303 let path = `${localesDir}/${locale}/${file}`; |
| 304 return new Promise((resolve, reject) => |
| 305 { |
| 306 fs.readFile(path, (err, data) => { |
| 307 if (err) |
| 308 { |
| 309 reject(err); |
| 310 } |
| 311 else |
| 312 { |
| 313 let json = {}; |
| 314 json.filename = file; |
| 315 json.locale = locale; |
| 316 json.strings = JSON.parse(data); |
| 317 resolve(json); |
| 318 } |
| 319 }); |
| 320 }).catch(reason => // Continue Promise.All even if rejected. |
| 321 { |
| 322 // Commented out log not to spam the output. |
| 323 // TODO: Think about more meaningful output without spaming |
| 324 // console.log(`Reading ${path} was rejected: ${reason}`); |
| 325 }); |
| 326 } |
| 327 |
| 328 /** |
| 329 * Reads CSV file |
| 330 * @param {String} file path |
| 331 * @return {Promise} Promise object |
| 332 */ |
| 333 function readCsv(filePath) |
| 334 { |
| 335 return new Promise((resolve, reject) => |
| 336 { |
| 337 fs.readFile(filePath, "utf8", (err, data) => { |
| 338 if (err) |
| 339 reject(err); |
| 340 else |
| 341 resolve(data); |
| 342 }); |
| 343 }); |
| 344 } |
| 345 |
| 346 /** |
| 347 * Read files and folder names inside of the directory |
| 348 * @param {String} dir patch of the folder |
| 349 * @return {Promise} Promise object |
| 350 */ |
| 351 function readDir(dir) |
| 352 { |
| 353 return new Promise((resolve, reject) => |
| 354 { |
| 355 fs.readdir(dir, (err, folders) => { |
| 356 if (err) |
| 357 reject(err); |
| 358 else |
| 359 resolve(folders); |
| 360 }); |
| 361 }); |
| 362 } |
| 363 |
| 364 /** |
| 365 * Executing mercurial commands on the system level |
| 366 * @param {String} command mercurial command ex.:"hg ..." |
| 367 * @return {Promise} Promise object containing output from the command |
| 368 */ |
| 369 function executeMercurial(command) |
| 370 { |
| 371 // Limit only to Mercurial commands to minimize the missuse risk |
| 372 if (command.substring(0, 3) !== "hg ") |
| 373 { |
| 374 console.error("You are only allowed to run Mercurial commands('hg ...')"); |
| 375 return; |
| 376 } |
| 377 |
| 378 return new Promise((resolve, reject) => |
| 379 { |
| 380 exec(command, (err, output) => |
| 381 { |
| 382 if (err) |
| 383 reject(err); |
| 384 else |
| 385 resolve(output); |
| 386 }); |
| 387 }); |
| 388 } |
| 389 |
| 390 // CLI |
| 391 let helpText = ` |
| 392 About: This script exports locales into .csv format |
| 393 Usage: node csv-export.js [option] [argument] |
| 394 Options: |
| 395 -f Name of the files to be exported ex.: -f firstRun.json |
| 396 option can be used multiple timeString. |
| 397 If ommited all files are being exported |
| 398 |
| 399 -o Output filename ex.: |
| 400 -f firstRun.json -o {hash}-firstRun.csv |
| 401 Placeholders: |
| 402 {hash} - Mercurial current revision hash |
| 403 {repo} - Name of the "Default" repository |
| 404 If ommited the output fileName is set to |
| 405 translations-{repo}-{hash}.csv |
| 406 |
| 407 -i Import file path ex: -i issue-reporter.csv |
| 408 `; |
| 409 |
| 410 let arguments = process.argv.slice(2); |
| 411 let stopExportScript = false; |
| 412 let filesFilter = []; // Filter to be used export to the fileNames inside |
| 413 |
| 414 for (let i = 0; i < arguments.length; i++) |
| 415 { |
| 416 switch (arguments[i]) |
| 417 { |
| 418 case "-h": |
| 419 console.log(helpText); |
| 420 stopExportScript = true; |
| 421 break; |
| 422 case "-f": |
| 423 if (!arguments[i + 1]) // check if argument following option is specified |
| 424 { |
| 425 console.error("Please specify the input filename"); |
| 426 stopExportScript = true; |
| 427 } |
| 428 else |
| 429 { |
| 430 filesFilter.push(arguments[i + 1]); |
| 431 } |
| 432 break; |
| 433 case "-o": |
| 434 if (!arguments[i + 1]) |
| 435 { |
| 436 console.error("Please specify the output filename"); |
| 437 stopExportScript = true; |
| 438 } |
| 439 else |
| 440 { |
| 441 outputFileName = arguments[i + 1]; |
| 442 } |
| 443 break; |
| 444 case "-i": |
| 445 if (!arguments[i + 1]) |
| 446 { |
| 447 console.error("Please specify the input filename"); |
| 448 } |
| 449 else |
| 450 { |
| 451 let importFile = arguments[i + 1]; |
| 452 importTranslations(importFile); |
| 453 } |
| 454 stopExportScript = true; |
| 455 break; |
| 456 } |
| 457 } |
| 458 |
| 459 if (!stopExportScript) |
| 460 exportTranslations(filesFilter); |
OLD | NEW |