Files
sabia/scripts/check-ui-i18n.mjs
T
Lucas RayanandClaude Sonnet 5 55e19a8e5b
CI / Format and clippy (push) Successful in 1m13s
CI / Dependency audit (push) Successful in 13s
CI / Tests (Linux) (push) Successful in 1m35s
Release / Create release (push) Successful in 7s
CI / Check desktop app (Linux) (push) Successful in 3m19s
Release / CLI (linux-aarch64) (push) Successful in 1m3s
Release / CLI (linux-x86_64) (push) Successful in 1m28s
Release / Desktop app (linux-aarch64) (push) Successful in 7m16s
Release / Desktop app (linux-x86_64) (push) Successful in 10m17s
Release / Desktop app (windows) (push) Successful in 4m54s
chore(i18n): finish translating code comments and scripts to English
Completes the English-only standardization for code, comments, scripts
and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en,
untouched). Covers the remaining crates/, apps/gui/ui/ and
apps/gui/src-tauri/ comments, .gitea/ workflow and template comments,
.githooks/ hooks, and the scripts/ source comments for the files
already renamed in a prior commit (check.sh, generate-logo.py,
install-hooks.sh).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:44:39 -03:00

297 lines
11 KiB
JavaScript

#!/usr/bin/env node
// Verifies that the English dictionary covers all user-facing interface strings.
//
// Static zero-dependency scanner: reads the UI files (JS and HTML) and the
// pt-BR messages emitted by the Rust core (errors, diagnostics, engine status),
// failing whenever a string looks like Portuguese and lacks a translation. Also
// rejects duplicate keys in the dictionary (in a JS object literal the last key wins silently).
//
// Usage: node scripts/check-ui-i18n.mjs
import { readFileSync, readdirSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
const rel = (p) => relative(root, p).replaceAll('\\', '/');
// ---------------------------------------------------------------- dictionary
const dictPath = join(root, 'apps/gui/ui/i18n-en.js');
const dictSource = readFileSync(dictPath, 'utf8');
const keyRe = /^\s*(['"])((?:(?!\1).)*)\1\s*:/gm;
const seen = new Map();
const duplicates = [];
let m;
while ((m = keyRe.exec(dictSource))) {
const key = m[2];
const line = dictSource.slice(0, m.index).split('\n').length;
if (seen.has(key)) duplicates.push({ key, first: seen.get(key), again: line });
else seen.set(key, line);
}
if (duplicates.length) {
console.error('Duplicate keys in apps/gui/ui/i18n-en.js:');
for (const d of duplicates) console.error(` ${JSON.stringify(d.key)} (lines ${d.first} and ${d.again})`);
process.exitCode = 1;
}
const { EN } = await import(new URL('../apps/gui/ui/i18n-en.js', import.meta.url).href);
const exact = new Set(Object.keys(EN).filter((k) => !k.includes('{n}')));
const patterns = Object.keys(EN)
.filter((k) => k.includes('{n}'))
.map((k) => {
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp('^' + parts.join('(.+?)') + '$', 's');
});
function translated(text) {
if (exact.has(text)) return true;
return patterns.some((re) => re.test(text));
}
// ---------------------------------------------------------------- heuristic
const ACCENT = /[áàâãäéèêëíìîïóòôõöúùûüçÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ]/;
const WORDS = [
'rede', 'redes', 'interface', 'interfaces', 'captura', 'capturar', 'modo', 'ativar', 'ativa',
'desativar', 'iniciar', 'parar', 'nenhum', 'nenhuma', 'selecione', 'selecionada', 'canal',
'sinal', 'segurança', 'clientes', 'cliente', 'oculta', 'oculto', 'privilégio', 'administrador',
'necessário', 'pra', 'própria', 'próprias', 'autorização', 'responsável', 'sessão', 'trilha',
'auditoria', 'registrada', 'registrado', 'local', 'adaptador', 'compatível', 'recarregue',
'escutando', 'levar', 'segundos', 'enquanto', 'canais', 'varridos', 'erro', 'desconhecido',
'desconhecida', 'não', 'sim', 'mais', 'menos', 'uma', 'uns', 'umas', 'aqui', 'onde', 'quando',
'como', 'qual', 'para', 'com', 'sem', 'dos', 'das', 'por', 'que', 'checando', 'checar',
'atualização', 'atualizado', 'trocar', 'possível', 'quebrável', 'quebrado', 'rompida',
'corrompida', 'inválido', 'inválida', 'entrada', 'dicionário', 'quadros', 'quantidade',
'injetar', 'encontrado', 'encontrada', 'suportada', 'permissão', 'insuficiente', 'instale',
'root', 'indisponível', 'plataforma', 'criada', 'ainda', 'ferramenta', 'ferramentas',
'exportar', 'importar', 'imprimir', 'baixar', 'recomendado', 'recomendada', 'aberta',
'aberto', 'inconclusivo', 'inconclusiva', 'capturado', 'capturada', 'conhecido',
'conhecida', 'opcional', 'opcionais', 'fechar', 'motivo', 'linha', 'tempo', 'quadro',
'tentativas', 'ambas',
];
const BRAND = /Sabiá|Português|English/gi;
const isPortuguese = (raw) => {
const s = raw.replace(BRAND, ' ');
return ACCENT.test(s) || WORDS.some((w) => new RegExp(`(^|[^a-zà-ú])${w}([^a-zà-ú]|$)`, 'i').test(s));
};
const ALLOW = new Set([
'Sabiá', 'Português', 'English', 'Auto', 'Wi-Fi', 'SSID', 'BSSID', 'MAC', 'AP', 'WPA', 'WPA2',
'WPA3', 'WEP', 'PMKID', 'EAPOL', 'PMK', 'PTK', 'pcap', 'radiotap', '802.11', 'dBm', 'GHz',
'MHz', 'RSSI', 'CAP_NET_ADMIN', 'CAP_NET_RAW', 'AF_PACKET', 'OK',
]);
const ALLOW_PREFIX = ['http://', 'https://', 'data:', 'file:', '#', '.', '/'];
const ALLOW_EXACT = new Set(['—', '-', '·']);
function suspicious(text) {
const s = text.replace(/\s+/g, ' ').trim();
if (!s || s.length < 2) return false;
if (ALLOW.has(s) || ALLOW_EXACT.has(s)) return false;
if (ALLOW_PREFIX.some((p) => s.startsWith(p))) return false;
if (!isPortuguese(s)) return false;
// Technical tokens: CSS selectors, classes, extensions, expressions
if (/^[.#\w-]+(\s*[,>+~]\s*[.#\w-]+)+$/.test(s)) return false;
if (/^[a-z0-9_.:/@%{}$-]+$/i.test(s) && !ACCENT.test(s)) return false;
return true;
}
// ---------------------------------------------------------------- extractors
function jsStrings(source, file, baseLine = 1) {
const out = [];
let i = 0;
let line = baseLine;
let prev = '';
const skipRegex = () => {
i++;
let inClass = false;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') { i += 2; continue; }
if (ch === '[') inClass = true;
else if (ch === ']') inClass = false;
else if (ch === '/' && !inClass) { i++; break; }
else if (ch === '\n') line++;
i++;
}
while (i < source.length && /[a-z]/i.test(source[i])) i++;
};
while (i < source.length) {
const c = source[i];
if (c === '\n') { line++; i++; prev = '\n'; continue; }
if (c === ' ' || c === '\t' || c === '\r') { i++; continue; }
if (c === '/' && source[i + 1] === '/') {
while (i < source.length && source[i] !== '\n') i++;
continue;
}
if (c === '/' && source[i + 1] === '*') {
i += 2;
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { if (source[i] === '\n') line++; i++; }
i += 2;
continue;
}
if (c === '/' && (/[=(:,[!&|?{};+\-*%^~<>]/.test(prev) || prev === 'return')) {
skipRegex();
prev = ')';
continue;
}
if (c === "'" || c === '"') {
const quote = c;
const startLine = line;
let value = '';
i++;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') {
const n = source[i + 1];
if (n === 'n') value += '\n';
else if (n === 't') value += '\t';
else if (n === 'r') value += '\r';
else if (n === '\n') line++;
else value += n ?? '';
i += 2;
continue;
}
if (ch === quote) { i++; break; }
if (ch === '\n') line++;
value += ch;
i++;
}
out.push({ text: value, file, line: startLine });
prev = ')';
continue;
}
if (c === '`') {
const startLine = line;
let value = '';
i++;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') {
const n = source[i + 1];
if (n === 'n') value += '\n';
else if (n === 't') value += '\t';
else if (n === '\n') line++;
else value += n ?? '';
i += 2;
continue;
}
if (ch === '`') { i++; break; }
if (ch === '$' && source[i + 1] === '{') {
value += '{n}';
i += 2;
const exprStart = i;
const exprLine = line;
let depth = 1;
while (i < source.length && depth > 0) {
if (source[i] === '{') depth++;
else if (source[i] === '}') depth--;
else if (source[i] === '\n') line++;
i++;
}
out.push(...jsStrings(source.slice(exprStart, i - 1), file, exprLine));
continue;
}
if (ch === '\n') line++;
value += ch;
i++;
}
out.push({ text: value, file, line: startLine });
prev = ')';
continue;
}
prev = c;
i++;
}
return out;
}
function htmlStrings(source, file, baseLine = 1) {
const out = [];
const lineAt = (idx) => baseLine + source.slice(0, idx).split('\n').length - 1;
// Text between tags (including leading and trailing text nodes)
let last = 0;
for (const m of source.matchAll(/<[^>]*>/g)) {
out.push({ text: source.slice(last, m.index), file, line: lineAt(last) });
last = m.index + m[0].length;
}
out.push({ text: source.slice(last), file, line: lineAt(last) });
// Translatable attributes (single or double quotes)
for (const m of source.matchAll(/\b(placeholder|title|aria-label|alt|data-empty)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
out.push({ text: m[2] ?? m[3], file, line: lineAt(m.index) });
}
return out;
}
// Extracts pt-BR messages from the Rust core: #[error("...")], .push("...".into()),
// "...".into(), and format!("...", ...). Placeholders ({name}, {0}, {} etc.) are
// normalized to {n}, matching the dictionary format.
function rustLabels(source, file) {
const out = [];
const lineAt = (idx) => source.slice(0, idx).split('\n').length;
const norm = (s) => s.replace(/\{[^}]*\}/g, '{n}');
const grab = (re) => {
for (const m of source.matchAll(re)) {
out.push({ text: norm(m[1]), file, line: lineAt(m.index) });
}
};
grab(/#\[error\(\s*"((?:[^"\\]|\\.)*)"\s*\)\]/g);
grab(/"((?:[^"\\]|\\.)*)"\s*\.into\(\)/g);
grab(/format!\(\s*"((?:[^"\\]|\\.)*)"/g);
return out;
}
// ---------------------------------------------------------------- collection
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js' && f !== 'vendor')
.map((f) => join(root, 'apps/gui/ui', f));
uiFiles.push(join(root, 'apps/gui/ui/index.html'));
const rustFiles = [
'crates/sabia-core/src/error.rs',
'crates/sabia-core/src/doctor.rs',
'crates/sabia-core/src/engine.rs',
].map((f) => join(root, f));
const found = [];
for (const file of uiFiles) {
const source = readFileSync(file, 'utf8');
if (file.endsWith('.html')) found.push(...htmlStrings(source, file));
else {
for (const str of jsStrings(source, file)) {
if (/<[a-z!/][^>]*>/i.test(str.text)) found.push(...htmlStrings(str.text, file, str.line));
else found.push(str);
}
}
}
for (const file of rustFiles) found.push(...rustLabels(readFileSync(file, 'utf8'), file));
const missing = [];
const seenMissing = new Set();
for (const { text, file, line } of found) {
if (!suspicious(text)) continue;
const key = text.replace(/\s+/g, ' ').trim();
if (translated(key)) continue;
const id = `${key}\u0000${rel(file)}\u0000${line}`;
if (seenMissing.has(id)) continue;
seenMissing.add(id);
missing.push({ text: key, file: rel(file), line });
}
if (missing.length) {
console.error('Untranslated Portuguese strings in apps/gui/ui/i18n-en.js:\n');
for (const x of missing) console.error(` ${x.file}:${x.line} ${JSON.stringify(x.text)}`);
console.error(`\n${missing.length} untranslated string(s).`);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(`i18n ok: ${found.length} strings checked, ${Object.keys(EN).length} dictionary keys.`);
}