add weblate script
This commit is contained in:
430
scripts/weblate
Executable file
430
scripts/weblate
Executable 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();
|
||||
Reference in New Issue
Block a user