import { projectRoot } from '../webpack/helpers'; const commander = require('commander'); const fs = require('fs'); const JSON5 = require('json5'); const _cliProgress = require('cli-progress'); const _ = require('lodash'); const program = new commander.Command(); program.version('1.0.0', '-v, --version'); const NEW_MESSAGE_TODO = '// TODO New key - Add a translation'; const MESSAGE_CHANGED_TODO = '// TODO Source message changed - Revise the translation'; const COMMENTS_CHANGED_TODO = '// TODO Source comments changed - Revise the translation'; const DEFAULT_SOURCE_FILE_LOCATION = 'src/assets/i18n/en.json5'; const LANGUAGE_FILES_LOCATION = 'src/assets/i18n'; parseCliInput(); /** * Parses the CLI input given by the user * If no parameters are set (standard usage) -> source file is default (set to DEFAULT_SOURCE_FILE_LOCATION) and all * other language files in the LANGUAGE_FILES_LOCATION are synced with this one in-place * (replaced with newly synced file) * If only target-file -t is set -> either -i in-place or -o output-file must be set * Source file can be set with -s if it should be something else than DEFAULT_SOURCE_FILE_LOCATION * * If any of the paths to files/dirs given by user are not valid, an error message is printed and script gets aborted */ function parseCliInput() { program .option('-d, --output-dir <output-dir>', 'output dir when running script on all language files; mutually exclusive with -o') .option('-t, --target-file <target>', 'target file we compare with and where completed output ends up if -o is not configured and -i is') .option('-i, --edit-in-place', 'edit-in-place; store output straight in target file; mutually exclusive with -o') .option('-s, --source-file <source>', 'source file to be parsed for translation', projectRoot(DEFAULT_SOURCE_FILE_LOCATION)) .option('-o, --output-file <output>', 'where output of script ends up; mutually exclusive with -i') .usage('([-d <output-dir>] [-s <source-file>]) || (-t <target-file> (-i | -o <output>) [-s <source-file>])') .parse(process.argv); if (!program.targetFile) { fs.readdirSync(projectRoot(LANGUAGE_FILES_LOCATION)).forEach(file => { if (!program.sourceFile.toString().endsWith(file)) { const targetFileLocation = projectRoot(LANGUAGE_FILES_LOCATION + "/" + file); console.log('Syncing file at: ' + targetFileLocation + ' with source file at: ' + program.sourceFile); if (program.outputDir) { if (!fs.existsSync(program.outputDir)) { fs.mkdirSync(program.outputDir); } const outputFileLocation = program.outputDir + "/" + file; console.log('Output location: ' + outputFileLocation); syncFileWithSource(targetFileLocation, outputFileLocation); } else { console.log('Replacing in target location'); syncFileWithSource(targetFileLocation, targetFileLocation); } } }); } else { if (program.targetFile && !checkIfPathToFileIsValid(program.targetFile)) { console.error('Directory path of target file is not valid.'); console.log(program.outputHelp()); process.exit(1); } if (program.targetFile && checkIfFileExists(program.targetFile) && !(program.editInPlace || program.outputFile)) { console.error('This target file already exists, if you want to overwrite this add option -i, or add an -o output location'); console.log(program.outputHelp()); process.exit(1); } if (!checkIfFileExists(program.sourceFile)) { console.error('Path of source file is not valid.'); console.log(program.outputHelp()); process.exit(1); } if (program.outputFile && !checkIfPathToFileIsValid(program.outputFile)) { console.error('Directory path of output file is not valid.'); console.log(program.outputHelp()); process.exit(1); } syncFileWithSource(program.targetFile, getOutputFileLocationIfExistsElseTargetFileLocation(program.targetFile)); } } /** * Creates chunk lists for both the source and the target files (for example en.json5 and nl.json5 respectively) * > Creates output chunks by comparing the source chunk with corresponding target chunk (based on key of translation) * > Writes the output chunks to a new valid lang.json5 file, either replacing the target file (-i in-place) * or sending it to an output file specified by the user * @param pathToTargetFile Valid path to target file to generate target chunks from * @param pathToOutputFile Valid path to output file to write output chunks to */ function syncFileWithSource(pathToTargetFile, pathToOutputFile) { const progressBar = new _cliProgress.SingleBar({}, _cliProgress.Presets.shades_classic); progressBar.start(100, 0); const sourceLines = []; const targetLines = []; const existingTargetFile = readFileIfExists(pathToTargetFile); existingTargetFile.toString().split("\n").forEach((function (line) { targetLines.push(line.trim()); })); progressBar.update(10); const sourceFile = readFileIfExists(program.sourceFile); sourceFile.toString().split("\n").forEach((function (line) { sourceLines.push(line.trim()); })); progressBar.update(20); const sourceChunks = createChunks(sourceLines, progressBar, false); const targetChunks = createChunks(targetLines, progressBar, true); const outputChunks = compareChunksAndCreateOutput(sourceChunks, targetChunks, progressBar); const file = fs.createWriteStream(pathToOutputFile); file.on('error', function (err) { console.error('Something went wrong writing to output file at: ' + pathToOutputFile + err) }); file.on('open', function() { file.write("{\n"); outputChunks.forEach(function (chunk) { progressBar.increment(); chunk.split("\n").forEach(function (line) { file.write((line === '' ? '' : ` ${line}`) + "\n"); }); }); file.write("\n}"); file.end(); }); file.on('finish', function() { const osName = process.platform; if (osName.startsWith("win")) { replaceLineEndingsToCRLF(pathToOutputFile); } }); progressBar.update(100); progressBar.stop(); } /** * For each of the source chunks: * - Determine if it's a new key-value => Add it to output, with source comments, source key-value commented, a message indicating it's new and the source-key value uncommented * - If it's not new, compare it with the corresponding target chunk and log the differences, see createNewChunkComparingSourceAndTarget * @param sourceChunks All the source chunks, split per key-value pair group * @param targetChunks All the target chunks, split per key-value pair group * @param progressBar The progressbar for the CLI * @return {Array} All the output chunks, split per key-value pair group */ function compareChunksAndCreateOutput(sourceChunks, targetChunks, progressBar) { const outputChunks = []; sourceChunks.map((sourceChunk) => { progressBar.increment(); if (sourceChunk.trim().length !== 0) { let newChunk = []; const sourceList = sourceChunk.split("\n"); const keyValueSource = sourceList[sourceList.length - 1]; const keySource = getSubStringBeforeLastString(keyValueSource, ":"); const commentSource = getSubStringBeforeLastString(sourceChunk, keyValueSource); const correspondingTargetChunk = targetChunks.find((targetChunk) => { return targetChunk.includes(keySource); }); // Create new chunk with: the source comments, the commented source key-value, the todos and either the old target key-value pair or if it's a new pair, the source key-value pair newChunk.push(removeWhiteLines(commentSource)); newChunk.push("// " + keyValueSource); if (correspondingTargetChunk === undefined) { newChunk.push(NEW_MESSAGE_TODO); newChunk.push(keyValueSource); } else { createNewChunkComparingSourceAndTarget(correspondingTargetChunk, sourceChunk, commentSource, keyValueSource, newChunk); } outputChunks.push(newChunk.filter(Boolean).join("\n")); } else { outputChunks.push(sourceChunk); } }); return outputChunks; } /** * If a corresponding target chunk is found: * - If old key value is not found in comments > Assumed it is new key * - If the target comments do not contain the source comments (because they have changed since last time) => Add comments changed message * - If the key-value in the target comments is not the same as the source key-value (because it changes since last time) => Add message changed message * - Add the old todos if they haven't been added already * - End with the original target key-value */ function createNewChunkComparingSourceAndTarget(correspondingTargetChunk, sourceChunk, commentSource, keyValueSource, newChunk) { let commentsOfSourceHaveChanged = false; let messageOfSourceHasChanged = false; const targetList = correspondingTargetChunk.split("\n"); const oldKeyValueInTargetComments = getSubStringWithRegex(correspondingTargetChunk, "\\s*\\/\\/\\s*\".*"); let keyValueTarget = targetList[targetList.length - 1]; if (!keyValueTarget.endsWith(",")) { keyValueTarget = keyValueTarget + ","; } if (oldKeyValueInTargetComments != null) { const oldKeyValueUncommented = getSubStringWithRegex(oldKeyValueInTargetComments[0], "\".*")[0]; if (!(_.isEmpty(correspondingTargetChunk) && _.isEmpty(commentSource)) && !removeWhiteLines(correspondingTargetChunk).includes(removeWhiteLines(commentSource.trim()))) { commentsOfSourceHaveChanged = true; newChunk.push(COMMENTS_CHANGED_TODO); } const parsedOldKey = JSON5.stringify("{" + oldKeyValueUncommented + "}"); const parsedSourceKey = JSON5.stringify("{" + keyValueSource + "}"); if (!_.isEqual(parsedOldKey, parsedSourceKey)) { messageOfSourceHasChanged = true; newChunk.push(MESSAGE_CHANGED_TODO); } addOldTodosIfNeeded(targetList, newChunk, commentsOfSourceHaveChanged, messageOfSourceHasChanged); } newChunk.push(keyValueTarget); } // Adds old todos found in target comments if they've not been added already function addOldTodosIfNeeded(targetList, newChunk, commentsOfSourceHaveChanged, messageOfSourceHasChanged) { targetList.map((targetLine) => { const foundTODO = getSubStringWithRegex(targetLine, "\\s*//\\s*TODO.*"); if (foundTODO != null) { const todo = foundTODO[0]; if (!((todo.includes(COMMENTS_CHANGED_TODO) && commentsOfSourceHaveChanged) || (todo.includes(MESSAGE_CHANGED_TODO) && messageOfSourceHasChanged))) { newChunk.push(todo); } } }); } /** * Creates chunks from an array of lines, each chunk contains either an empty line or a grouping of comments with their corresponding key-value pair * @param lines Array of lines, to be grouped into chunks * @param progressBar Progressbar of the CLI * @return {Array} Array of chunks, grouped by key-value and their corresponding comments or an empty line */ function createChunks(lines, progressBar, creatingTarget) { const chunks = []; let nextChunk = []; let onMultiLineComment = false; lines.map((line) => { progressBar.increment(); if (line.length === 0) { chunks.push(line); } if (isOneLineCommentLine(line)) { nextChunk.push(line); } if (onMultiLineComment) { nextChunk.push(line); if (isEndOfMultiLineComment(line)) { onMultiLineComment = false; } } if (isStartOfMultiLineComment(line)) { nextChunk.push(line); onMultiLineComment = true; } if (isKeyValuePair(line)) { nextChunk.push(line); const newMessageLineIfExists = nextChunk.find((lineInChunk) => lineInChunk.trim().startsWith(NEW_MESSAGE_TODO)); if (newMessageLineIfExists === undefined || !creatingTarget) { chunks.push(nextChunk.join("\n")); } nextChunk = []; } }); return chunks; } function readFileIfExists(pathToFile) { if (checkIfFileExists(pathToFile)) { try { return fs.readFileSync(pathToFile, 'utf8'); } catch (e) { if (e instanceof Error) { console.error('Error:', e.stack); } } } return null; } function isOneLineCommentLine(line) { return (line.startsWith("//")); } function isStartOfMultiLineComment(line) { return (line.startsWith("/*")); } function isEndOfMultiLineComment(line) { return (line.endsWith("*/")); } function isKeyValuePair(line) { return (line.startsWith("\"")); } function getSubStringWithRegex(string, regex) { return string.match(regex); } function getSubStringBeforeLastString(string, char) { const lastCharIndex = string.lastIndexOf(char); return string.substr(0, lastCharIndex); } function getOutputFileLocationIfExistsElseTargetFileLocation(targetLocation) { if (program.outputFile) { return program.outputFile; } return targetLocation; } function checkIfPathToFileIsValid(pathToCheck) { if (!pathToCheck.includes("/")) { return true; } return checkIfFileExists(getPathOfDirectory(pathToCheck)); } function checkIfFileExists(pathToCheck) { return fs.existsSync(pathToCheck); } function getPathOfDirectory(pathToCheck) { return getSubStringBeforeLastString(pathToCheck, "/"); } function removeWhiteLines(string) { return string.replace(/^(?=\n)$|^\s*|\s*$|\n\n+/gm, "") } /** * Replaces UNIX \n LF line endings to windows \r\n CRLF line endings. * @param filePath Path to file whose line endings are being converted */ function replaceLineEndingsToCRLF(filePath) { const data = readFileIfExists(filePath); const result = data.replace(/\n/g,"\r\n"); fs.writeFileSync(filePath, result, 'utf8'); }