| LEFT | RIGHT | 
|    1 /* |    1 /* | 
|    2  * This file is part of Adblock Plus <https://adblockplus.org/>, |    2  * This file is part of Adblock Plus <https://adblockplus.org/>, | 
|    3  * Copyright (C) 2006-present eyeo GmbH |    3  * Copyright (C) 2006-present eyeo GmbH | 
|    4  * |    4  * | 
|    5  * Adblock Plus is free software: you can redistribute it and/or modify |    5  * Adblock Plus is free software: you can redistribute it and/or modify | 
|    6  * it under the terms of the GNU General Public License version 3 as |    6  * it under the terms of the GNU General Public License version 3 as | 
|    7  * published by the Free Software Foundation. |    7  * published by the Free Software Foundation. | 
|    8  * |    8  * | 
|    9  * Adblock Plus is distributed in the hope that it will be useful, |    9  * Adblock Plus is distributed in the hope that it will be useful, | 
|   10  * but WITHOUT ANY WARRANTY; without even the implied warranty of |   10  * but WITHOUT ANY WARRANTY; without even the implied warranty of | 
|   11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the |   11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | 
|   12  * GNU General Public License for more details. |   12  * GNU General Public License for more details. | 
|   13  * |   13  * | 
|   14  * You should have received a copy of the GNU General Public License |   14  * You should have received a copy of the GNU General Public License | 
|   15  * along with Adblock Plus.  If not, see <http://www.gnu.org/licenses/>. |   15  * along with Adblock Plus.  If not, see <http://www.gnu.org/licenses/>. | 
|   16  */ |   16  */ | 
|   17  |   17  | 
|   18 /* globals process */ |   18 /* globals process */ | 
|   19  |   19  | 
|   20 "use strict"; |   20 "use strict"; | 
|   21  |   21  | 
|   22 const fs = require("fs"); |   22 const fs = require("fs"); | 
|   23 const path = require("path"); |   23 const path = require("path"); | 
|   24 const csv = require("csv"); |   24 const csv = require("csv"); | 
|   25 const {promisify} = require("util"); |   25 const {promisify} = require("util"); | 
|   26 const execFile = promisify(require("child_process").execFile); |   26 const execFile = promisify(require("child_process").execFile); | 
|   27 const csvParser = promisify(csv.parse); |   27 const csvParser = promisify(csv.parse); | 
|   28 const readFile = promisify(fs.readFile); |   28 const readFile = promisify(fs.readFile); | 
 |   29 const writeFile = promisify(fs.writeFile); | 
|   29 const glob = promisify(require("glob").glob); |   30 const glob = promisify(require("glob").glob); | 
|   30 const readJsonPromised = promisify(readJson); |  | 
|   31  |   31  | 
|   32 const localesDir = "locale"; |   32 const localesDir = "locale"; | 
|   33 const defaultLocale = "en_US"; |   33 const defaultLocale = "en_US"; | 
|   34  |   34  | 
|   35 let headers = ["Filename", "StringID", "Description", "Placeholders", |   35 const headers = ["Filename", "StringID", "Description", "Placeholders", | 
|   36                defaultLocale]; |   36                  defaultLocale]; | 
|   37 let outputFileName = "translations-{repo}-{hash}.csv"; |   37 let outputFileName = "translations.csv"; | 
|   38  |   38  | 
|   39 /** |   39 /** | 
|   40  * Export existing translation - files into CSV file |   40  * Export existing translation - files into CSV file | 
|   41  */ |   41  */ | 
|   42 function exportTranslations() |   42 function exportTranslations() | 
|   43 { |   43 { | 
|   44   let mercurialCommands = []; |   44   glob(`${localesDir}/**/*.json`).then((filePaths) => | 
|   45   // Get Hash |  | 
|   46   mercurialCommands.push(execFile("hg", ["id", "-i"])); |  | 
|   47   // Get repo path |  | 
|   48   mercurialCommands.push(execFile("hg", ["paths", "default"])); |  | 
|   49   Promise.all(mercurialCommands).then((outputs) => |  | 
|   50   { |  | 
|   51     // Remove line endings and "+" sign from the end of the hash |  | 
|   52     let [hash, filePath] = outputs.map((output) => |  | 
|   53                            output.stdout.replace(/\+\n|\n$/, "")); |  | 
|   54     // Update name of the file to be output |  | 
|   55     outputFileName = outputFileName.replace("{hash}", hash); |  | 
|   56     outputFileName = outputFileName.replace("{repo}", path.basename(filePath)); |  | 
|   57  |  | 
|   58     // Read all available locales and default files |  | 
|   59     return glob(`${localesDir}/**/*.json`); |  | 
|   60   }).then((filePaths) => |  | 
|   61   { |   45   { | 
|   62     // Reading all existing translations files |   46     // Reading all existing translations files | 
|   63     return Promise.all(filePaths.map((filePath) => readJsonPromised(filePath))); |   47     return Promise.all(filePaths.map((filePath) => readJson(filePath))); | 
|   64   }).then(csvFromJsonFileObjects); |   48   }).then(csvFromJsonFileObjects); | 
|   65 } |   49 } | 
|   66  |   50  | 
|   67 /** |   51 /** | 
|   68  * Creating Matrix which reflects output CSV file |   52  * Creating Matrix which reflects output CSV file | 
|   69  * @param  {Object[]} fileObjects - array of file objects created by readJson |   53  * @param  {Object[]} fileObjects - array of file objects created by readJson | 
|   70  */ |   54  */ | 
|   71 function csvFromJsonFileObjects(fileObjects) |   55 function csvFromJsonFileObjects(fileObjects) | 
|   72 { |   56 { | 
|   73   let locales = []; |   57   const locales = []; | 
 |   58   const fileNames = []; | 
|   74   // Create Object tree from the Objects array, for easier search |   59   // Create Object tree from the Objects array, for easier search | 
|   75   // ex.: {dektop-options.json: {en_US: {...}, {de: {...}, {ru: {...}}} |   60   // ex.: {dektop-options.json: {en_US: {...}, {de: {...}, {ru: {...}}} | 
|   76   let dataTreeObj = Object.create(null); |   61   const dataTreeObj = Object.create(null); | 
|   77   for (let fileObject of fileObjects) |   62   for (const fileObject of fileObjects) | 
|   78   { |   63   { | 
|   79     const {fileName, locale, strings} = fileObject; |   64     const {fileName, locale, strings} = fileObject; | 
|   80  |   65  | 
|   81     if (!locales.includes(locale)) |   66     if (locale != defaultLocale && !locales.includes(locale)) | 
|   82       locales.push(locale); |   67       locales.push(locale); | 
|   83  |   68  | 
|   84     if (!dataTreeObj[fileName]) |   69     if ((!filesFilter.length || filesFilter.includes(fileName)) && | 
|   85       dataTreeObj[fileName] = {}; |   70          !fileNames.includes(fileName)) | 
|   86     if (!dataTreeObj[fileName][locale]) |   71       fileNames.push(fileName); | 
|   87       dataTreeObj[fileName][locale] = {}; |   72  | 
 |   73     if (!(fileName in dataTreeObj)) | 
 |   74       dataTreeObj[fileName] = Object.create(null); | 
 |   75  | 
|   88     dataTreeObj[fileName][locale] = strings; |   76     dataTreeObj[fileName][locale] = strings; | 
|   89   } |   77   } | 
|   90  |  | 
|   91   let fileNames = Object.keys(dataTreeObj); |  | 
|   92   if (filesFilter.length) |  | 
|   93     fileNames = fileNames.filter((item) => filesFilter.includes(item)); |  | 
|   94  |  | 
|   95   locales = locales.filter((locale) => locale != defaultLocale).sort(); |  | 
|   96   // Create two dimensional strings array that reflects CSV structure |   78   // Create two dimensional strings array that reflects CSV structure | 
|   97   let csvArray = [headers.concat(locales)]; |   79   const csvArray = [headers.concat(locales)]; | 
|   98   for (let fileName of fileNames) |   80   for (const fileName of fileNames) | 
|   99   { |   81   { | 
|  100     let strings = dataTreeObj[fileName][defaultLocale]; |   82     const strings = dataTreeObj[fileName][defaultLocale]; | 
|  101     for (let stringID of Object.keys(strings)) |   83     for (const stringID of Object.keys(strings)) | 
|  102     { |   84     { | 
|  103       let fileObj = dataTreeObj[fileName]; |   85       const fileObj = dataTreeObj[fileName]; | 
|  104       let {description, message, placeholders} = strings[stringID]; |   86       const {description, message, placeholders} = strings[stringID]; | 
|  105       let row = [fileName, stringID, description || "", |   87       const row = [fileName, stringID, description || "", | 
|  106                  JSON.stringify(placeholders), message]; |   88                    JSON.stringify(placeholders), message]; | 
|  107  |   89  | 
|  108       for (let locale of locales) |   90       for (const locale of locales) | 
|  109       { |   91       { | 
|  110         let localeFileObj = fileObj[locale]; |   92         const localeFileObj = fileObj[locale]; | 
|  111         let isTranslated = !!(localeFileObj && localeFileObj[stringID]); |   93         const isTranslated = !!(localeFileObj && localeFileObj[stringID]); | 
|  112         row.push(isTranslated ? localeFileObj[stringID].message : ""); |   94         row.push(isTranslated ? localeFileObj[stringID].message : ""); | 
|  113       } |   95       } | 
|  114       csvArray.push(row); |   96       csvArray.push(row); | 
|  115     } |   97     } | 
|  116   } |   98   } | 
|  117   arrayToCsv(csvArray); |   99   arrayToCsv(csvArray); | 
|  118 } |  100 } | 
|  119  |  101  | 
|  120 /** |  102 /** | 
|  121  * Import strings from the CSV file |  103  * Import strings from the CSV file | 
|  122  * @param  {string} filePath - CSV file path to import from |  104  * @param  {string} filePath - CSV file path to import from | 
|  123  */ |  105  */ | 
|  124 function importTranslations(filePath) |  106 function importTranslations(filePath) | 
|  125 { |  107 { | 
|  126   readFile(filePath, "utf8").then((fileObjects) => |  108   readFile(filePath, "utf8").then((fileObjects) => | 
|  127   { |  109   { | 
|  128     return csvParser(fileObjects); |  110     return csvParser(fileObjects); | 
|  129   }).then((dataMatrix) => |  111   }).then((dataMatrix) => | 
|  130   { |  112   { | 
|  131     let headLocales = dataMatrix.shift().slice(4); |  113     const headLocales = dataMatrix.shift().slice(4); | 
|  132     let dataTreeObj = {}; |  114     const dataTreeObj = {}; | 
|  133     for (let rowId in dataMatrix) |  115     for (const rowId in dataMatrix) | 
|  134     { |  116     { | 
|  135       let row = dataMatrix[rowId]; |  117       const row = dataMatrix[rowId]; | 
|  136       let [currentFilename, stringId, description, placeholder, ...messages] = |  118       let [currentFilename, stringId, description, placeholder, ...messages] = | 
|  137         row; |  119         row; | 
|  138       if (!stringId) |  120       if (!stringId) | 
|  139         continue; |  121         continue; | 
|  140  |  122  | 
|  141       stringId = stringId.trim(); |  123       stringId = stringId.trim(); | 
|  142       // Check if it's the filename row |  124       // Check if it's the filename row | 
|  143       if (!dataTreeObj[currentFilename]) |  125       if (!dataTreeObj[currentFilename]) | 
|  144         dataTreeObj[currentFilename] = {}; |  126         dataTreeObj[currentFilename] = {}; | 
|  145  |  127  | 
|  146       description = description.trim(); |  128       description = description.trim(); | 
|  147       for (let i = 0; i < headLocales.length; i++) |  129       for (let i = 0; i < headLocales.length; i++) | 
|  148       { |  130       { | 
|  149         let locale = headLocales[i].trim(); |  131         const locale = headLocales[i].trim(); | 
|  150         let message = messages[i].trim(); |  132         const message = messages[i].trim(); | 
|  151         if (!message) |  133         if (!message) | 
|  152           continue; |  134           continue; | 
|  153  |  135  | 
|  154         // Create Object tree from the Objects array, for easier search |  136         // Create Object tree from the Objects array, for easier search | 
|  155         // ex.: {dektop-options.json: {en_US: {...}, {de: {...}, {ru: {...}}} |  137         // ex.: {dektop-options.json: {en_US: {...}, {de: {...}, {ru: {...}}} | 
|  156         if (!dataTreeObj[currentFilename][locale]) |  138         if (!dataTreeObj[currentFilename][locale]) | 
|  157           dataTreeObj[currentFilename][locale] = {}; |  139           dataTreeObj[currentFilename][locale] = {}; | 
|  158  |  140  | 
|  159         let localeObj = dataTreeObj[currentFilename][locale]; |  141         const localeObj = dataTreeObj[currentFilename][locale]; | 
|  160         localeObj[stringId] = {}; |  142         localeObj[stringId] = {}; | 
|  161         let stringObj = localeObj[stringId]; |  143         const stringObj = localeObj[stringId]; | 
|  162  |  144  | 
|  163         // We keep string descriptions only in default locale files |  145         // We keep string descriptions only in default locale files | 
|  164         if (locale == defaultLocale) |  146         if (locale == defaultLocale && description) | 
|  165           stringObj.description = description; |  147           stringObj.description = description; | 
|  166  |  148  | 
|  167         stringObj.message = message; |  149         stringObj.message = message; | 
|  168         if (placeholder) |  150         if (placeholder) | 
|  169           stringObj.placeholders = JSON.parse(placeholder); |  151           stringObj.placeholders = JSON.parse(placeholder); | 
|  170       } |  152       } | 
|  171     } |  153     } | 
|  172     writeJson(dataTreeObj); |  154     writeJson(dataTreeObj); | 
|  173   }); |  155   }); | 
|  174 } |  156 } | 
|  175  |  157  | 
|  176 /** |  158 /** | 
|  177  * Write locale files according to dataTreeObj |  159  * Write locale files according to dataTreeObj | 
|  178  * @param  {Object} dataTreeObj - ex.: |  160  * @param  {Object} dataTreeObj - ex.: | 
|  179  * {dektop-options.json: {en_US: {...}, {de: {...}, {ru: {...}}} |  161  * {dektop-options.json: {en_US: {...}, {de: {...}, {ru: {...}}} | 
|  180  */ |  162  */ | 
|  181 function writeJson(dataTreeObj) |  163 function writeJson(dataTreeObj) | 
|  182 { |  164 { | 
|  183   for (let fileName in dataTreeObj) |  165   for (const fileName in dataTreeObj) | 
|  184   { |  166   { | 
|  185     for (let locale in dataTreeObj[fileName]) |  167     for (const locale in dataTreeObj[fileName]) | 
|  186     { |  168     { | 
|  187       let filePath = path.join(localesDir, locale, fileName); |  169       const filePath = path.join(localesDir, locale, fileName); | 
|  188       let sortedJSON = orderJSON(dataTreeObj[fileName][locale]); |  170       const sortedJson = sortJson(dataTreeObj[fileName][locale]); | 
|  189       let fileString = JSON.stringify(sortedJSON, null, 2); |  171       let fileString = JSON.stringify(sortedJson, null, 2); | 
|  190  |  172  | 
|  191       // Newline at end of file to match Coding Style |  173       // Newline at end of file to match Coding Style | 
|  192       if (locale == defaultLocale) |  174       if (locale == defaultLocale) | 
|  193         fileString += "\n"; |  175         fileString += "\n"; | 
|  194       fs.writeFile(filePath, fileString, "utf8", (err) => |  176       writeFile(filePath, fileString, "utf8").then(() => | 
|  195       { |  177       { | 
|  196         if (err) |  178         console.log(`Updated: ${filePath}`); // eslint-disable-line no-console | 
|  197         { |  179       }).catch((err) => | 
|  198           console.error(err); |  180       { | 
|  199         } |  181         console.error(err); | 
|  200         else |  | 
|  201         { |  | 
|  202           console.log(`Updated: ${filePath}`); |  | 
|  203         } |  | 
|  204       }); |  182       }); | 
|  205     } |  183     } | 
|  206   } |  184   } | 
|  207 } |  185 } | 
|  208  |  186  | 
|  209 /** |  187 /** | 
|  210  * This function currently relies on V8 to sort the object by keys |  188  * This function currently relies on V8 to sort the object by keys | 
|  211  * @param {Object} unordered - json object |  189  * @param {Object} unordered - json object | 
|  212  * @returns {Object} |  190  * @returns {Object} | 
|  213  */ |  191  */ | 
|  214 function orderJSON(unordered) |  192 function sortJson(unordered) | 
|  215 { |  193 { | 
|  216   const ordered = {}; |  194   const ordered = {}; | 
|  217   for (let key of Object.keys(unordered).sort()) |  195   for (const key of Object.keys(unordered).sort()) | 
|  218   { |  196   { | 
|  219     ordered[key] = unordered[key]; |  197     ordered[key] = unordered[key]; | 
|  220     if (unordered[key].placeholders) |  198     if (unordered[key].placeholders) | 
|  221       ordered[key].placeholders = orderJSON(unordered[key].placeholders); |  199       ordered[key].placeholders = sortJson(unordered[key].placeholders); | 
|  222  |  200  | 
|  223     ordered[key] = unordered[key]; |  201     ordered[key] = unordered[key]; | 
|  224   } |  202   } | 
|  225   return ordered; |  203   return ordered; | 
|  226 } |  204 } | 
|  227  |  205  | 
|  228 /** |  206 /** | 
|  229  * Convert two dimensional array to the CSV file |  207  * Convert two dimensional array to the CSV file | 
|  230  * @param  {Object[]} csvArray - array to convert from |  208  * @param  {Object[]} csvArray - array to convert from | 
|  231  */ |  209  */ | 
|  232 function arrayToCsv(csvArray) |  210 function arrayToCsv(csvArray) | 
|  233 { |  211 { | 
|  234   csv.stringify(csvArray, (err, output) => |  212   csv.stringify(csvArray, (err, output) => | 
|  235   { |  213   { | 
|  236     fs.writeFile(outputFileName, output, "utf8", (error) => |  214     writeFile(outputFileName, output, "utf8").then(() => | 
|  237     { |  215     { | 
|  238       if (!error) |  216       // eslint-disable-next-line no-console | 
|  239         console.log(`${outputFileName} is created`); |  217       console.log(`${outputFileName} is created`); | 
|  240       else |  218     }).catch((error) => | 
|  241         console.error(error); |  219     { | 
 |  220       console.error(error); | 
|  242     }); |  221     }); | 
|  243   }); |  222   }); | 
|  244 } |  223 } | 
|  245  |  224  | 
|  246 /** |  225 /** | 
|  247  * Reads JSON file and assign filename and locale to it |  226  * Reads JSON file and assign filename and locale to it | 
|  248  * @param {string} filePath - ex.: "locales/en_US/desktop-options.json" |  227  * @param {string} filePath - ex.: "locales/en_US/desktop-options.json" | 
|  249  * @param {function} callback - fileName, locale and strings of locale file |  228  * @returns {Promise<Object>} resolves fileName, locale and strings of the | 
|  250  *                   Parameters: |  229  *                            locale file | 
|  251  *                     * Error message |  230  */ | 
|  252  *                     * Object containing fileName, locale and strings |  231 function readJson(filePath) | 
|  253  */ |  232 { | 
|  254 function readJson(filePath, callback) |  233   return readFile(filePath, "utf8").then((data) => | 
|  255 { |  234   { | 
|  256   let {dir, base} = path.parse(filePath); |  235     const {dir, base} = path.parse(filePath); | 
|  257   fs.readFile(filePath, "utf8", (err, data) => |  236     const locale = dir.split(path.sep).pop(); | 
|  258   { |  237     const strings = JSON.parse(data); | 
|  259     if (err) |  238     return {fileName: base, locale, strings}; | 
|  260     { |  | 
|  261       callback(err); |  | 
|  262     } |  | 
|  263     else |  | 
|  264     { |  | 
|  265       let locale = dir.split(path.sep).pop(); |  | 
|  266       let strings = JSON.parse(data); |  | 
|  267       callback(null, {fileName: base, locale, strings}); |  | 
|  268     } |  | 
|  269   }); |  239   }); | 
|  270 } |  240 } | 
|  271  |  241  | 
|  272 /** |  242 /** | 
|  273  * Exit process and log error message |  243  * Exit process and log error message | 
|  274  * @param {String} error error message |  244  * @param {String} error error message | 
|  275  */ |  245  */ | 
|  276 function exitProcess(error) |  246 function exitProcess(error) | 
|  277 { |  247 { | 
|  278   console.error(error); |  248   console.error(error); | 
|  279   process.exit(1); |  249   process.exit(1); | 
|  280 } |  250 } | 
|  281  |  251  | 
|  282 // CLI |  252 // CLI | 
|  283 let helpText = ` |  253 const helpText = ` | 
|  284 About: Converts locale files between CSV and JSON formats |  254 About: Converts locale files between CSV and JSON formats | 
|  285 Usage: csv-export.js [option] [argument] |  255 Usage: csv-export.js [option] [argument] | 
|  286 Options: |  256 Options: | 
|  287   -f [FILENAME]         Name of the files to be exported ex.: -f firstRun.json |  257   -f [FILENAME]         Name of the files to be exported ex.: -f firstRun.json | 
|  288                         option can be used multiple times. |  258                         option can be used multiple times. | 
|  289                         If omitted all files are being exported |  259                         If omitted all files are being exported | 
|  290  |  260  | 
|  291   -o [FILENAME]         Output filename ex.: |  261   -o [FILENAME]         Output filename ex.: | 
|  292                         -f firstRun.json -o {hash}-firstRun.csv |  262                         -f firstRun.json -o firstRun.csv | 
|  293                         Placeholders: |  | 
|  294                           {hash} - Mercurial current revision hash |  | 
|  295                           {repo} - Name of the "Default" repository |  | 
|  296                         If omitted the output fileName is set to |  263                         If omitted the output fileName is set to | 
|  297                         translations-{repo}-{hash}.csv |  264                         translations.csv | 
|  298  |  265  | 
|  299   -i [FILENAME]         Import file path ex: -i issue-reporter.csv |  266   -i [FILENAME]         Import file path ex: -i issue-reporter.csv | 
|  300 `; |  267 `; | 
|  301  |  268  | 
|  302 let argv = process.argv.slice(2); |  269 const argv = process.argv.slice(2); | 
|  303 let stopExportScript = false; |  270 let stopExportScript = false; | 
|  304 // Filter to be used export to the fileNames inside |  271 // Filter to be used export to the fileNames inside | 
|  305 let filesFilter = []; |  272 const filesFilter = []; | 
|  306  |  273  | 
|  307 for (let i = 0; i < argv.length; i++) |  274 for (let i = 0; i < argv.length; i++) | 
|  308 { |  275 { | 
|  309   switch (argv[i]) |  276   switch (argv[i]) | 
|  310   { |  277   { | 
|  311     case "-h": |  278     case "-h": | 
|  312       console.log(helpText); |  279       console.log(helpText); // eslint-disable-line no-console | 
|  313       stopExportScript = true; |  280       stopExportScript = true; | 
|  314       break; |  281       break; | 
|  315     case "-f": |  282     case "-f": | 
|  316       if (!argv[i + 1]) |  283       if (!argv[i + 1]) | 
|  317       { |  284       { | 
|  318         exitProcess("Please specify the input filename"); |  285         exitProcess("Please specify the input filename"); | 
|  319       } |  286       } | 
|  320       filesFilter.push(argv[i + 1]); |  287       filesFilter.push(argv[i + 1]); | 
|  321       break; |  288       break; | 
|  322     case "-o": |  289     case "-o": | 
|  323       if (!argv[i + 1]) |  290       if (!argv[i + 1]) | 
|  324       { |  291       { | 
|  325         exitProcess("Please specify the output filename"); |  292         exitProcess("Please specify the output filename"); | 
|  326       } |  293       } | 
|  327       outputFileName = argv[i + 1]; |  294       outputFileName = argv[i + 1]; | 
|  328       break; |  295       break; | 
|  329     case "-i": |  296     case "-i": | 
|  330       if (!argv[i + 1]) |  297       if (!argv[i + 1]) | 
|  331       { |  298       { | 
|  332         exitProcess("Please specify the import file"); |  299         exitProcess("Please specify the import file"); | 
|  333       } |  300       } | 
|  334       let importFile = argv[i + 1]; |  301       const importFile = argv[i + 1]; | 
|  335       importTranslations(importFile); |  302       importTranslations(importFile); | 
|  336       stopExportScript = true; |  303       stopExportScript = true; | 
|  337       break; |  304       break; | 
|  338   } |  305   } | 
|  339 } |  306 } | 
|  340  |  307  | 
|  341 if (!stopExportScript) |  308 if (!stopExportScript) | 
|  342   exportTranslations(filesFilter); |  309   exportTranslations(filesFilter); | 
| LEFT | RIGHT |