#!/usr/bin/env node import { execFileSync, execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import readline from 'readline'; import { program } from 'commander'; import superagent from '@cloudron/superagent'; const WEBLATE_URL = 'https://translate.cloudron.io'; const PROJECT = 'cloudron'; const COMPONENT = 'dashboard'; const TRANSLATION_DIR = path.resolve('./dashboard/public/translation'); const SRC_DIRS = [path.resolve('./dashboard/src'), path.resolve('./src')]; function getToken() { const token = process.env.WEBLATE_API_KEY; if (!token) { console.error('Error: WEBLATE_API_KEY environment variable is not set.'); console.error('Get your token from https://translate.cloudron.io/accounts/profile/#api'); process.exit(1); } return token; } function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function getNestedValue(obj, dottedKey) { return dottedKey.split('.').reduce((current, key) => current && current[key], obj); } function flattenObject(obj, prefix = '') { const result = {}; for (const key of Object.keys(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { Object.assign(result, flattenObject(obj[key], fullKey)); } else { result[fullKey] = obj[key]; } } return result; } async function listFiles(dir) { let result = []; const entries = await fs.promises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { result = result.concat(await listFiles(fullPath)); } else if (entry.isFile()) { result.push(fullPath); } } return result; } async function getSourceFiles() { let files = []; for (const dir of SRC_DIRS) { if (!fs.existsSync(dir)) continue; files = files.concat(await listFiles(dir)); } return files.filter(f => f.endsWith('.vue') || f.endsWith('.js')); } function getFindUsageFilesForKey(key) { try { const output = execFileSync('git', ['grep', '-l', '-F', '--untracked', key], { encoding: 'utf-8' }); return output.trim().length ? output.trim().split('\n') : []; } catch (error) { if (error.status === 1) return []; throw error; } } async function yesno(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(question + ' [y/n] ', (answer) => { rl.close(); resolve(answer.trim().toLowerCase() === 'y'); }); }); } function readAllTranslations(key) { const translationFiles = fs.readdirSync(TRANSLATION_DIR).filter(f => f.endsWith('.json')); const translations = {}; for (const file of translationFiles) { const lang = path.basename(file, '.json'); const data = JSON.parse(fs.readFileSync(path.join(TRANSLATION_DIR, file), 'utf-8')); const value = getNestedValue(data, key); if (value !== undefined) translations[lang] = value; } return translations; } async function findTranslations(query) { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { console.error('Please provide a non-empty search text.'); process.exit(1); } const translationFiles = fs.readdirSync(TRANSLATION_DIR).filter(f => f.endsWith('.json')); const matches = []; for (const file of translationFiles) { const lang = path.basename(file, '.json'); const data = JSON.parse(fs.readFileSync(path.join(TRANSLATION_DIR, file), 'utf-8')); const flat = flattenObject(data); for (const [key, value] of Object.entries(flat)) { if (typeof value !== 'string') continue; if (!value.toLowerCase().includes(normalizedQuery)) continue; matches.push({ key, lang, value }); } } if (matches.length === 0) { console.error(`No matches found for "${query}".`); process.exit(1); } const groupedByKey = new Map(); for (const match of matches) { if (!groupedByKey.has(match.key)) groupedByKey.set(match.key, []); groupedByKey.get(match.key).push(match); } const keyUsage = new Map(); for (const key of groupedByKey.keys()) { keyUsage.set(key, getFindUsageFilesForKey(key)); } const sortedKeys = [...groupedByKey.keys()].sort(); console.log(`Found ${matches.length} match(es) across ${sortedKeys.length} key(s):\n`); for (const key of sortedKeys) { console.log(` ${key}`); const keyMatches = groupedByKey.get(key).sort((a, b) => a.lang.localeCompare(b.lang)); for (const { lang, value } of keyMatches) { const snippet = value.length > 120 ? value.slice(0, 120) + '...' : value; console.log(` [${lang}] ${snippet}`); } const usedIn = keyUsage.get(key); if (usedIn.length > 0) { console.log(' used in:'); for (const file of usedIn) console.log(` - ${file}`); } else { console.log(' used in: (no matches in repository)'); } console.log(); } } async function deleteUnit(token, key) { const result = await superagent .get(`${WEBLATE_URL}/api/units/`) .set('Authorization', `Token ${token}`) .query({ q: `language:en AND component:${COMPONENT} AND key:${key}` }); if (!result.body.results || result.body.results.length === 0) { console.error(` Warning: could not find unit for key "${key}" in Weblate.`); return false; } const id = result.body.results[0].id; console.log(` Deleting unit ${id}...`); await superagent .del(`${WEBLATE_URL}/api/units/${id}/`) .set('Authorization', `Token ${token}`); return true; } // ---- download ---- function downloadTranslations() { console.log('Downloading translations...'); const zipPath = path.resolve('./lang.zip'); execSync(`curl -s "${WEBLATE_URL}/api/components/${PROJECT}/${COMPONENT}/file/" -o "${zipPath}"`); console.log('Unpacking...'); execSync(`unzip -jo "${zipPath}" -d "${TRANSLATION_DIR}"`); fs.unlinkSync(zipPath); console.log('Done. Translations downloaded.'); } // ---- get ---- function getTranslation(key) { downloadTranslations(); const translations = readAllTranslations(key); if (Object.keys(translations).length === 0) { console.error(`Key "${key}" not found in any language file.`); process.exit(1); } for (const [lang, value] of Object.entries(translations)) { console.log(` ${lang}: ${value}`); } } // ---- add ---- async function addTranslation(key, value) { const token = getToken(); console.log(`\nAdding key "${key}" with value "${value}" in Weblate...`); await superagent .post(`${WEBLATE_URL}/api/translations/${PROJECT}/${COMPONENT}/en/units/`) .set('Authorization', `Token ${token}`) .send({ key, value: [value] }); console.log('Downloading updated translation files...'); downloadTranslations(); console.log('Done.'); } // ---- update ---- async function updateTranslation(key, value) { const token = getToken(); console.log(`\nLooking up key "${key}" in Weblate...`); const result = await superagent .get(`${WEBLATE_URL}/api/units/`) .set('Authorization', `Token ${token}`) .query({ q: `language:en AND component:${COMPONENT} AND key:${key}` }); if (!result.body.results || result.body.results.length === 0) { console.error(`Error: key "${key}" not found in Weblate.`); process.exit(1); } const unit = result.body.results[0]; console.log(` Found unit ${unit.id}, current value: "${unit.source[0]}"`); console.log(` Updating to: "${value}"...`); await superagent .patch(`${WEBLATE_URL}/api/units/${unit.id}/`) .set('Authorization', `Token ${token}`) .send({ target: [value], state: 20 /* numeric code for translated */ }); console.log('Downloading updated translation files...'); downloadTranslations(); console.log('Done.'); } // ---- del ---- async function delTranslation(key) { const token = getToken(); console.log(`\nDeleting key "${key}" from Weblate...`); if (await deleteUnit(token, key)) { console.log('Downloading updated translation files...'); downloadTranslations(); console.log('Done.'); } else { process.exit(1); } } // ---- duplicates ---- async function findDuplicates() { const enPath = path.join(TRANSLATION_DIR, 'en.json'); const data = JSON.parse(fs.readFileSync(enPath, 'utf-8')); const flat = flattenObject(data); const keys = Object.keys(flat); // group keys by their value const byValue = new Map(); for (const key of keys) { const value = flat[key]; if (!byValue.has(value)) byValue.set(value, []); byValue.get(value).push(key); } const duplicates = [...byValue.entries()] .filter(([, group]) => group.length > 1) .sort((a, b) => b[1].length - a[1].length); if (duplicates.length === 0) { console.log('No duplicate values found.'); return; } console.log(`Found ${duplicates.length} values with multiple keys:\n`); for (const [value, group] of duplicates) { const truncated = value.length > 60 ? value.substring(0, 60) + '...' : value; console.log(` "${truncated}"`); for (const key of group) console.log(` - ${key}`); console.log(); } } // ---- unused ---- async function findUnused() { const enPath = path.join(TRANSLATION_DIR, 'en.json'); const data = JSON.parse(fs.readFileSync(enPath, 'utf-8')); const flat = flattenObject(data); const keys = Object.keys(flat); console.log(`Scanning ${keys.length} keys against source files...\n`); const files = await getSourceFiles(); const fileContents = await Promise.all(files.map(f => fs.promises.readFile(f, 'utf-8'))); const unused = keys.filter(key => !fileContents.some(content => content.includes(key))); if (unused.length === 0) { console.log('No unused keys found.'); return; } console.log(`Found ${unused.length} unused key(s):\n`); for (const key of unused) console.log(` ${key}`); console.log(); } // ---- rename ---- async function renameInSourceCode(oldKey, newKey) { const files = await getSourceFiles(); let totalReplacements = 0; const modifiedFiles = []; for (const file of files) { let content = await fs.promises.readFile(file, 'utf-8'); if (!content.includes(oldKey)) continue; const relPath = path.relative(process.cwd(), file); let fileModified = false; // safe: inside $t('...') or $t("...") const safePattern = new RegExp(`(\\$t\\(['"])${escapeRegex(oldKey)}(['",)])`, 'g'); const safeMatches = content.match(safePattern); if (safeMatches) { content = content.replace(safePattern, `$1${newKey}$2`); totalReplacements += safeMatches.length; fileModified = true; console.log(` ${relPath}: ${safeMatches.length} $t() replacement(s)`); } // suspicious: key still appears outside $t() after safe replacements if (content.includes(oldKey)) { const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { if (!lines[i].includes(oldKey)) continue; console.log(`\n Suspicious match in ${relPath}:${i + 1}:`); const start = Math.max(0, i - 1); const end = Math.min(lines.length, i + 2); for (let j = start; j < end; j++) { const marker = j === i ? '>>>' : ' '; console.log(` ${marker} ${j + 1}: ${lines[j]}`); } const replace = await yesno(' Replace this occurrence?'); if (replace) { lines[i] = lines[i].replaceAll(oldKey, newKey); totalReplacements++; fileModified = true; } } content = lines.join('\n'); } if (fileModified) { await fs.promises.writeFile(file, content, 'utf-8'); modifiedFiles.push(relPath); } } return { totalReplacements, modifiedFiles }; } async function renameTranslation(oldKey, newKey) { const token = getToken(); // Step 1: download fresh translations console.log(`\nStep 1: Downloading latest translation files...`); downloadTranslations(); // Step 2: rename in source code console.log(`\nStep 2: Renaming in source code...`); const { totalReplacements, modifiedFiles } = await renameInSourceCode(oldKey, newKey); console.log(`\n ${totalReplacements} replacement(s) in ${modifiedFiles.length} file(s).`); if (totalReplacements === 0) { console.error(`\nNo occurrences of "${oldKey}" found in source code. Aborting.`); process.exit(1); } // Step 3: read existing translations from all JSON files console.log(`\nStep 3: Reading existing translations for key "${oldKey}"...`); const translations = readAllTranslations(oldKey); for (const [lang, value] of Object.entries(translations)) { console.log(` ${lang}: "${typeof value === 'string' ? value.substring(0, 60) : value}"`); } if (!translations.en) { console.error(`Error: key "${oldKey}" not found in en.json. Aborting.`); process.exit(1); } console.log(`\nFound translations in ${Object.keys(translations).length} languages.`); // Step 4: add the new key in Weblate for each language console.log(`\nStep 4: Adding new key "${newKey}" in Weblate...`); console.log(` Adding en (source)...`); await superagent .post(`${WEBLATE_URL}/api/translations/${PROJECT}/${COMPONENT}/en/units/`) .set('Authorization', `Token ${token}`) .send({ key: newKey, value: [translations.en] }); for (const [lang, value] of Object.entries(translations)) { if (lang === 'en') continue; console.log(` Adding ${lang}...`); try { await superagent .post(`${WEBLATE_URL}/api/translations/${PROJECT}/${COMPONENT}/${lang}/units/`) .set('Authorization', `Token ${token}`) .send({ key: newKey, value: [value], state: 20 }); } catch (err) { console.warn(` Warning: failed to add ${lang}: ${err.message}`); } } // Step 5: delete the old source unit from Weblate console.log(`\nStep 5: Deleting old key "${oldKey}" from Weblate...`); await deleteUnit(token, oldKey); // Step 6: re-download translations console.log('\nStep 6: Downloading updated translation files...'); downloadTranslations(); console.log(`\nDone! Renamed "${oldKey}" -> "${newKey}".`); } // ---- CLI ---- program .name('weblate') .description('Weblate translation management tools'); program .command('download') .description('Download latest translations from Weblate') .action(() => downloadTranslations()); program .command('get ') .description('Show the value of a key across all languages') .action((key) => getTranslation(key)); program .command('find ') .description('Reverse-lookup translation text across languages and show matching key paths') .action(async (text) => await findTranslations(text)); program .command('add ') .description('Add a new English source string in Weblate') .action(async (key, value) => await addTranslation(key, value)); program .command('update ') .description('Update the English value of an existing key in Weblate') .action(async (key, value) => await updateTranslation(key, value)); program .command('del ') .description('Delete a translation key from Weblate (all languages)') .action(async (key) => await delTranslation(key)); program .command('rename ') .description('Rename a translation key across Weblate and source code') .action(async (oldKey, newKey) => await renameTranslation(oldKey, newKey)); program .command('duplicates') .description('Find English keys that share the same value') .action(async () => await findDuplicates()); program .command('unused') .description('Find translation keys not referenced in source code') .action(async () => await findUnused()); program.parse();