Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code

Side by Side Diff: csv-export.js

Issue 29636585: Issue 6171 - create CSV exporter and importer for translations (Closed)
Patch Set: Created Dec. 19, 2017, 7:40 p.m.
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments.
Jump to:
View unified diff | Download patch
« no previous file with comments | « README.md ('k') | no next file » | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(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)
saroyanm 2017/12/19 19:48:23 Important translations order are different from th
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);
OLDNEW
« no previous file with comments | « README.md ('k') | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld