BlueWallet/scripts/find-english-leftovers.js

236 lines
10 KiB
JavaScript

// Lints loc/*.json for keys whose translated value still equals the English source.
//
// Such "leftovers" usually mean the translator never localized the string and the
// entry can be removed so the runtime cleanly falls back to en.json. A few
// categories legitimately match English and are skipped:
//
// 1. Pure brand / acronym / unit values (BTC, BIP47, sat/vByte, …).
// 2. Pure format strings whose only non-placeholder content is punctuation
// or an allowed brand suffix (e.g. "+{amt1} ({amt2})", "{number} BTC").
// 3. Languages listed in LANG_EXCEPTIONS:
// - `pcm` (Nigerian Pidgin) is English-derived; many UI labels stay verbatim.
// - `da_dk` / `nl_nl` — master human translations + per-locale glossary
// mandate English crypto loanwords throughout (Wallet, Multisig Vault,
// Coin Control, Payment Code, mempool, watch-only, hardware wallet, …).
// Flagging these would produce noise, not signal — they are intentional.
// 4. Individual keys listed in KEY_EXCEPTIONS that are intentionally identical
// to English in many (≥5) locales: brand-style compounds, technical units,
// universal short labels (ID/Port/Status/time abbreviations).
//
// Exit code is non-zero when violations are found so the script can be wired
// into `npm run lint`.
const fs = require('fs');
const path = require('path');
const LOC_DIR = path.join(__dirname, '..', 'loc');
const SOURCE = 'en.json';
const LANG_EXCEPTIONS = new Set([
'pcm.json', // Pidgin English — reuses English words verbatim
'da_dk.json', // Danish — master prefers English crypto loanwords
'nl_nl.json', // Dutch — master 55% English, glossary explicitly loanword
]);
// Keys (section.key) intentionally identical to English in many locales.
// Grouped by reason: brand/protocol, pure-format, universal short labels,
// Bitcoin-specific technical terms.
const KEY_EXCEPTIONS = new Set([
// --- Pure format strings (placeholders + punctuation only) ---
'transactions.received_with_amount', // "+{amt1} ({amt2})"
'transactions.list_conf', // "Conf: {number}"
'transactions.details_inputs_count', // "Inputs ({count})"
'transactions.details_outputs_count', // "Outputs ({count})"
'transactions.confirmations_lowercase',// "{confirmations} confirmations"
'multisig.fee', // "Fee: {number}"
'multisig.fee_btc', // "{number} BTC"
'multisig.what_is_vault_numberOfWallets', // " {m}-of-{n} multisig "
// --- Brand / protocol / acronym strings ---
'lndViewInvoice.preimage', // "Pre-image" — Lightning protocol term
'lndViewInvoice.sats', // "sats." — unit suffix
'bip47.bip47_explain_subtitle', // "BIP47"
'multisig.legacy_title', // "Legacy" — Bitcoin script type
'settings.general_continuity', // "Continuity" — Apple feature brand
'units.BTC',
'units.sats',
'units.sat_vbyte',
'units.MAX',
// --- Bitcoin/Lightning technical terms often kept English ---
'_.seed', // "Seed" — BIP39 mnemonic
'wallets.import_passphrase', // "Passphrase" — BIP39
'wallets.import_passphrase_title',
'wallets.details_master_fingerprint', // "Master Fingerprint" — BIP32
'wallets.identity_pubkey', // "Identity Pubkey" — LN
'wallets.details_derivation_path', // "derivation path" — BIP32/44
'wallets.import_derivation_title', // "Derivation path"
'wallets.details_multisig_type', // "multisig"
'wallets.xpub_title', // "Wallet XPUB"
'settings.block_explorer', // "Block Explorer"
'transactions.offchain', // "Offchain"
'transactions.onchain', // "Onchain"
'transactions.details_tx_hex', // "Tx Hex"
'multisig.multisig_vault', // "Multisig Vault" — brand-style compound
'multisig.default_label', // "Multisig Vault"
'multisig.quorum_header', // "Quorum"
'cc.header', // "Coin Control"
'bip47.payment_code', // "Payment Code"
'bip47.notif_tx', // "Notification transaction"
// --- Universal short UI labels / technical units (≥5 locales) ---
'transactions.details_id', // "ID" — 42+ locales
'_.port', // "Port"
'_.ssl_port', // "SSL Port"
'_.clipboard', // "Clipboard"
'_.no', // "No" — Romance cognate
'settings.electrum_status', // "Status"
'cc.sort_status', // "Status"
'settings.widgets', // "Widgets"
'send.fee_10m', // "10m" — time abbrev
'send.fee_3h', // "3h"
'send.fee_1d', // "1d"
'send.fee_satvbyte', // "in sat/vByte"
'send.create_satoshi_per_vbyte', // "Satoshi per vByte"
'send.create_details', // "Details"
'send.input_total', // "Total:"
'send.create_memo', // "Memo" — loanword in many locales
'send.dynamic_start', // "Start"
'send.dynamic_stop', // "Stop"
'send.broadcastError', // "Error"
'errors.error', // "Error"
'multisig.header', // "Send"
'transactions.details_section', // "Details"
'transactions.details_explorer', // "explorer"
'transactions.date', // "Date"
'transactions.details_note', // "Note"
'transactions.transaction', // "Transaction"
'transactions.details_title', // "Transaction"
'receive.details_label', // "Description"
'settings.general', // "General"
'settings.notifications', // "Notifications"
'settings.password', // "Password"
'settings.privacy', // "Privacy"
'settings.plausible_deniability', // "Plausible Deniability"
'plausibledeniability.title', // "Plausible Deniability"
'wallets.details_title', // "Wallet" — de_de loanword
'wallets.list_title', // "Wallets"
'wallets.wallets', // "Wallets"
'wallets.add_wallet_type', // "Type"
'wallets.details_type', // "Type"
'wallets.add_entropy', // "Entropy"
'entropy.title', // "Entropy"
'cc.sort_label', // "Label"
'addresses.sign_placeholder_message', // "Message"
'addresses.sign_placeholder_signature',// "Signature"
'addresses.transactions', // "Transactions"
'bip47.contacts', // "Contacts"
]);
// Single tokens that are legitimate English-equal values (brands, acronyms,
// units, technical loanwords). When the entire string is one of these we keep it.
const BRAND_TOKENS = new Set([
'BTC', 'BIP38', 'BIP39', 'BIP47', 'PSBT', 'XPUB', 'MAX', 'sats',
'sat/vByte', 'sat/vB', 'RBF', 'CPFP', 'LND', 'LNDhub', 'Electrum',
'GitHub', 'BlueWallet', 'Bitcoin', 'Lightning', 'Tor', 'Orbot',
'SilentPayment', 'GroundControl', 'AirDrop', 'iCloud', 'Telegram',
'Specter', 'Coldcard', 'Pre-image', 'Legacy', 'OK', 'ETA', 'P2SH',
'PIN', 'QR', 'SSL', 'URL', 'URI', 'MIT', 'JSON', 'CSV', 'HD',
]);
function isAllowedValue(val) {
if (val == null) return true;
const trimmed = String(val).trim();
if (trimmed === '') return true;
if (BRAND_TOKENS.has(trimmed)) return true;
// Pure format string: strip placeholders, then strip allowed punctuation /
// digits, see if only brand tokens remain.
if (trimmed.includes('{')) {
const withoutPlaceholders = trimmed.replace(/\{[a-zA-Z0-9_]+\}/g, '').trim();
if (withoutPlaceholders === '') return true;
// Split on whitespace; allow leftover punctuation around tokens.
const tokens = withoutPlaceholders
.split(/\s+/)
.map(t => t.replace(/^[^A-Za-z0-9-]+|[^A-Za-z0-9/-]+$/g, ''))
.filter(Boolean);
if (tokens.length === 0) return true;
if (tokens.every(t => BRAND_TOKENS.has(t))) return true;
}
return false;
}
function findLeftovers(en, target) {
const out = [];
for (const section of Object.keys(en)) {
const enSec = en[section];
if (typeof enSec !== 'object' || enSec === null) continue;
const tSec = target[section];
if (typeof tSec !== 'object' || tSec === null) continue;
for (const key of Object.keys(enSec)) {
if (!(key in tSec)) continue;
if (tSec[key] !== enSec[key]) continue;
const fullKey = `${section}.${key}`;
if (KEY_EXCEPTIONS.has(fullKey)) continue;
if (isAllowedValue(tSec[key])) continue;
out.push({ section, key, value: enSec[key] });
}
}
return out;
}
function main() {
const enPath = path.join(LOC_DIR, SOURCE);
if (!fs.existsSync(enPath)) {
console.error(`Cannot find source file: ${enPath}`);
process.exit(2);
}
const en = JSON.parse(fs.readFileSync(enPath, 'utf8'));
const files = fs.readdirSync(LOC_DIR)
.filter(f => f.endsWith('.json') && f !== SOURCE)
.sort();
let totalViolations = 0;
const report = [];
for (const f of files) {
if (LANG_EXCEPTIONS.has(f)) continue;
let data;
try {
data = JSON.parse(fs.readFileSync(path.join(LOC_DIR, f), 'utf8'));
} catch (e) {
console.error(`${f}: parse error — ${e.message}`);
process.exitCode = 1;
continue;
}
const leftovers = findLeftovers(en, data);
if (leftovers.length > 0) {
totalViolations += leftovers.length;
report.push({ file: f, leftovers });
}
}
if (totalViolations === 0) {
console.log('OK — no English-leftover values found in localized files.');
process.exit(0);
}
console.log(`Found ${totalViolations} English-leftover value(s) across ${report.length} file(s):\n`);
for (const { file, leftovers } of report) {
console.log(` ${file} (${leftovers.length}):`);
for (const l of leftovers) {
const preview = l.value.length > 80 ? l.value.slice(0, 77) + '...' : l.value;
console.log(` ${l.section}.${l.key} = ${JSON.stringify(preview)}`);
}
}
console.log(
'\nRemove these keys from the locale files (so the runtime falls back ' +
'to en.json), or translate them, or add to KEY_EXCEPTIONS / LANG_EXCEPTIONS ' +
'in scripts/find-english-leftovers.js if the English match is intentional.'
);
process.exit(1);
}
main();