add weblate script

This commit is contained in:
Girish Ramakrishnan
2026-02-23 14:45:22 +01:00
parent 2ac76ad852
commit 7ef19b318a

430
scripts/weblate Executable file
View File

@@ -0,0 +1,430 @@
#!/usr/bin/env node
import { 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'));
}
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 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 <key>')
.description('Show the value of a key across all languages')
.action((key) => getTranslation(key));
program
.command('add <key> <value>')
.description('Add a new English source string in Weblate')
.action(async (key, value) => await addTranslation(key, value));
program
.command('update <key> <value>')
.description('Update the English value of an existing key in Weblate')
.action(async (key, value) => await updateTranslation(key, value));
program
.command('del <key>')
.description('Delete a translation key from Weblate (all languages)')
.action(async (key) => await delTranslation(key));
program
.command('rename <oldKey> <newKey>')
.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();