diff --git a/scripts/weblate b/scripts/weblate new file mode 100755 index 000000000..2a7af599f --- /dev/null +++ b/scripts/weblate @@ -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 ') + .description('Show the value of a key across all languages') + .action((key) => getTranslation(key)); + +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();