diff --git a/common/translations-cli/extract.js b/common/translations-cli/extract.js new file mode 100644 index 000000000..1fa2f67f4 --- /dev/null +++ b/common/translations-cli/extract.js @@ -0,0 +1,61 @@ +//@ts-check +const fs = require('fs'); +const { promisify } = require('util'); + +const { getFiles } = require('./helpers'); + +const readFilePromise = promisify(fs.readFile); + +const translationRegex = /_t\(\s*['"]([^'"]+)['"]\s*,\s*\{\s*defaultMessage\s*:\s*['"]([^'"]+)['"]\s*\}/g; + +/** + * @param {string} file + * + * @returns {Promise>} + */ +async function extractTranslationsFromFile(file) { + /** @type {Record} */ + const translations = {}; + const content = await readFilePromise(file, 'utf-8'); + let match; + + while ((match = translationRegex.exec(content)) !== null) { + const [_, key, defaultText] = match; + translations[key] = defaultText; + } + + return translations; +} + +/** + * @param {string[]} directories + * @param {string[]} extensions + * + * @returns {Promise>} + */ +async function extractAllTranslations(directories, extensions) { + try { + /** @type {Record} */ + const allTranslations = {}; + + for (const dir of directories) { + const files = await getFiles(dir, extensions); + + for (const file of files) { + const fileTranslations = await extractTranslationsFromFile(file); + Object.assign(allTranslations, fileTranslations); + } + } + + console.log(`Total translations found: ${Object.keys(allTranslations).length}`); + + return allTranslations; + } catch (error) { + console.error('Error extracting translations:', error); + throw error; + } +} +module.exports = { + extractTranslationsFromFile, + extractAllTranslations, +}; diff --git a/common/translations-cli/helpers.js b/common/translations-cli/helpers.js new file mode 100644 index 000000000..512e330be --- /dev/null +++ b/common/translations-cli/helpers.js @@ -0,0 +1,134 @@ +//@ts-check +const path = require('path'); +const fs = require('fs'); + +/** + * @param {string} file + * @param {string[]} extensions + * + * @returns {boolean} + */ +function hasValidExtension(file, extensions) { + return extensions.includes(path.extname(file).toLowerCase()); +} + +/** + * @param {string} dir + * @param {string[]} extensions + * + * @returns {Promise} + */ +async function getFiles(dir, extensions) { + const files = await fs.promises.readdir(dir); + const allFiles = await Promise.all( + files.map(async file => { + const filePath = path.join(dir, file); + const stats = await fs.promises.stat(filePath); + + if (stats.isDirectory()) { + return getFiles(filePath, extensions); + } else if (stats.isFile() && hasValidExtension(file, extensions)) { + return filePath; + } + return null; + }) + ); + + const validFiles = /** @type {string[]} */ (allFiles.flat().filter(file => file !== null)); + + return validFiles; +} + +/** + * @param {string | string[]} value + * + * @returns {string} + */ +function formatDefaultValue(value) { + if (Array.isArray(value)) { + return value.join(', '); + } + return value; +} + +const scriptDir = getScriptDir(); +/** @param {string} file + * + * @returns {string} + */ +function resolveFile(file) { + if (path.isAbsolute(file)) { + return file; + } + + return path.resolve(scriptDir, '..', '..', file); +} + +/** @param {string[]} dirs + * + * @returns {string[]} + */ +function resolveDirs(dirs) { + return dirs.map(resolveFile); +} + +/** + * @param {string[]} extensions + * + * @returns {string[]} + */ +function resolveExtensions(extensions) { + return extensions.map(ext => (ext.startsWith('.') ? ext : `.${ext}`)); +} + +function getScriptDir() { + if (require.main?.filename) { + return path.dirname(require.main.filename); + } + + if ('pkg' in process && process.pkg) { + return path.dirname(process.execPath); + } + + return __dirname; +} + +/** + * @param {string} file + */ +function ensureFileDirExists(file) { + const dir = path.dirname(file); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +/** + * @param {Record} existingTranslations - Previously extracted translations + * @param {Record} newTranslations - Newly extracted translations + * @returns {{ added: string[], removed: string[], updated: string[] }} Translation changes + */ +const getTranslationChanges = (existingTranslations, newTranslations) => { + const existingKeys = new Set(Object.keys(existingTranslations || {})); + const newKeys = new Set(Object.keys(newTranslations)); + + const added = [...newKeys].filter(key => !existingKeys.has(key)); + const removed = [...existingKeys].filter(key => !newKeys.has(key)); + const updated = [...newKeys].filter( + key => existingKeys.has(key) && existingTranslations[key] !== newTranslations[key] + ); + + return { added, removed, updated }; +}; + +module.exports = { + hasValidExtension, + getFiles, + formatDefaultValue, + resolveFile, + resolveDirs, + resolveExtensions, + ensureFileDirExists, + getTranslationChanges, +}; diff --git a/common/translations-cli/program.js b/common/translations-cli/program.js new file mode 100644 index 000000000..1ff90b119 --- /dev/null +++ b/common/translations-cli/program.js @@ -0,0 +1,89 @@ +//@ts-check +const fs = require('fs'); +const { program } = require('commander'); +const { + resolveDirs, + resolveExtensions, + resolveFile, + ensureFileDirExists, + getTranslationChanges, +} = require('./helpers'); +const { extractAllTranslations } = require('./extract'); + +/** + * @typedef {{ extensions: string[], directories: string[], outputFile: string}} Config + * @typedef {Config & { verbose?: boolean }} Options + */ + +/** @type {Config} */ +const defaultConfig = { + extensions: ['.js', '.ts', '.svelte'], + directories: ['app', 'packages/web'], + outputFile: './translations/en.json', +}; + +program.name('dbgate-translations-cli').description('CLI tool for managing translation').version('1.0.0'); + +program + .command('extract') + .description('Extract translation keys from source files') + .option('-d, --directories ', 'directories to search', defaultConfig.directories) + .option('-e, --extensions ', 'file extensions to process', defaultConfig.extensions) + .option('-o, --outputFile ', 'output file path', defaultConfig.outputFile) + .option('-v, --verbose', 'verbose mode') + .action(async (/** @type {Options} */ options) => { + try { + const { directories, extensions, outputFile, verbose } = options; + + const resolvedRirectories = resolveDirs(directories); + const resolvedExtensions = resolveExtensions(extensions); + + const translations = await extractAllTranslations(resolvedRirectories, resolvedExtensions); + + const resolvedOutputFile = resolveFile(outputFile); + ensureFileDirExists(resolvedOutputFile); + + /** @type {Record} */ + let existingTranslations = {}; + if (fs.existsSync(resolvedOutputFile)) { + existingTranslations = JSON.parse(fs.readFileSync(resolvedOutputFile, 'utf-8')); + } + + const { added, removed, updated } = getTranslationChanges(existingTranslations, translations); + + console.log('\nTranslation changes:'); + console.log(`- Added: ${added.length} keys`); + console.log(`- Removed: ${removed.length} keys`); + console.log(`- Updated: ${updated.length} keys`); + console.log(`- Total: ${Object.keys(translations).length} keys`); + + if (verbose) { + if (added.length > 0) { + console.log('\nNew keys:'); + added.forEach(key => console.log(` + ${key}`)); + } + + if (removed.length > 0) { + console.log('\nRemoved keys:'); + removed.forEach(key => console.log(` - ${key}`)); + } + + if (updated.length > 0) { + console.log('\nUpdated keys:'); + updated.forEach(key => { + console.log(` ~ ${key}`); + console.log(` Old: ${existingTranslations[key]}`); + console.log(` New: ${translations[key]}`); + }); + } + } + + fs.writeFileSync(resolvedOutputFile, JSON.stringify(translations, null, 2)); + } catch (error) { + console.error(error); + console.error('Error during extraction:', error.message); + process.exit(1); + } + }); + +program.parse();