2026-02-23 14:45:22 +01:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
2026-02-25 15:14:38 +01:00
|
|
|
import { execFileSync, execSync } from 'child_process';
|
2026-02-23 14:45:22 +01:00
|
|
|
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'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 15:14:38 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 14:45:22 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 15:14:38 +01:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 14:45:22 +01:00
|
|
|
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));
|
|
|
|
|
|
2026-02-25 15:14:38 +01:00
|
|
|
program
|
|
|
|
|
.command('find <text>')
|
|
|
|
|
.description('Reverse-lookup translation text across languages and show matching key paths')
|
|
|
|
|
.action(async (text) => await findTranslations(text));
|
|
|
|
|
|
2026-02-23 14:45:22 +01:00
|
|
|
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();
|