fix(i18n): eliminate portuguese text leaks across english routes
- Localize skip-to-content accessibility link and breadcrumb navigation - Convert section layouts to dynamic generateMetadata with English titles, descriptions, and structured data - Eliminate mixed language expressions and translate remaining SoC connection pin warnings and descriptions - Translate component registry items, categories, tags, and IDF Component Manager guide - Add strict identical string verification to scripts/check-i18n.mjs with technical allowlist Co-Authored-By: Antigravity <noreply@google.com>
This commit is contained in:
1 parent
c1bd2c7e11
commit
47c55515bb
35 files changed
+2353
-1874
No files matched your search
+218
-24
@@ -5,7 +5,7 @@
|
||||
* Implementa os critérios de aceite da seção 10 (itens 1 e 2) da spec docs/specs/i18n.md:
|
||||
* 1. Consistência estrutural de catálogos e dados (chaves idênticas ao pt).
|
||||
* 2. Ausência de valores vazios.
|
||||
* 3. Detecção de strings idênticas ao pt sem estarem na allowlist técnica.
|
||||
* 3. Detecção estrita de strings idênticas ao pt sem allowlist (em messages, data e lib/data).
|
||||
* 4. Verificação estrita de ausência de referências ao livro fora do pt (seção 7).
|
||||
*
|
||||
* Uso: npm run check:i18n
|
||||
@@ -29,7 +29,7 @@ const CORES = {
|
||||
const LOCALES = ["pt", "en", "es", "fr", "de", "zh-CN"];
|
||||
const FORBIDDEN_BOOK_TERMS = /\b(livro|libro|livre|buch|书|book|isbn|amazon|uiclap|capítulo|capitulo|chapter|chapitre|kapitel|章节)\b/i;
|
||||
|
||||
// Termos técnicos e nomes próprios permitidos permanecerem iguais ao pt
|
||||
// Allowlist estrita: nomes próprios, marcas, siglas e termos técnicos aceitos como idênticos
|
||||
const TECHNICAL_ALLOWLIST = new Set([
|
||||
"ESPDocs",
|
||||
"Lucas Rayan Guerra",
|
||||
@@ -57,6 +57,16 @@ const TECHNICAL_ALLOWLIST = new Set([
|
||||
"Matter",
|
||||
"ESP-RainMaker",
|
||||
"ESP-Matter",
|
||||
"ESP-ADF",
|
||||
"ESP-SR",
|
||||
"ESP-IoT-Solution",
|
||||
"ESP-NOW & Mesh",
|
||||
"ESP-Zigbee-SDK",
|
||||
"ESP-WHO",
|
||||
"ESP-NN",
|
||||
"ESP-DSP",
|
||||
"Rust on ESP32",
|
||||
"Arduino Core",
|
||||
"Xtensa",
|
||||
"RISC-V",
|
||||
"Wi-Fi",
|
||||
@@ -84,6 +94,7 @@ const TECHNICAL_ALLOWLIST = new Set([
|
||||
"SoC",
|
||||
"PSRAM",
|
||||
"SRAM",
|
||||
"ROM",
|
||||
"Flash",
|
||||
"OTA",
|
||||
"DMA",
|
||||
@@ -94,6 +105,17 @@ const TECHNICAL_ALLOWLIST = new Set([
|
||||
"DCDC",
|
||||
"LVGL",
|
||||
"IDF",
|
||||
"TinyUSB",
|
||||
"LittleFS",
|
||||
"CBOR",
|
||||
"RFC 8949",
|
||||
"Cloud & AIoT Framework",
|
||||
"Smart Home Framework",
|
||||
"Smart Home & Mesh",
|
||||
"{count} frameworks",
|
||||
"C / C++",
|
||||
"C++",
|
||||
"Python",
|
||||
"12",
|
||||
"78",
|
||||
"100%",
|
||||
@@ -105,6 +127,44 @@ const TECHNICAL_ALLOWLIST = new Set([
|
||||
"6",
|
||||
]);
|
||||
|
||||
function isTechnicalAllowlisted(val, path = "") {
|
||||
if (typeof val !== "string") return true;
|
||||
const s = val.trim();
|
||||
if (TECHNICAL_ALLOWLIST.has(s)) return true;
|
||||
// Campos que são metadados técnicos, URLs, identificadores ou configs de código
|
||||
if (/(^|\.)(id|namespace|name|nome_completo|versao|categoria|oficial|popular|urlRegistry|urlRepo|exemploYaml|icone|cor|placa|site_oficial|repositorio_github|documentacao_oficial|datasheet|manual_tecnico|guia_de_programacao|gpio|uart|i2c|i2s|spi|adc|dac|touch|pino|funcoes|rotulo|chave|slug)(\[\d+\])*$/i.test(path)) {
|
||||
return true;
|
||||
}
|
||||
if (s.startsWith("http://") || s.startsWith("https://") || s.startsWith("/") || s.startsWith("mailto:")) return true;
|
||||
// Versões semver, tags e identificadores
|
||||
if (/^\^?[0-9]+(\.[0-9]+)*([a-zA-Z0-9.-]*)$/.test(s)) return true;
|
||||
// Emojis / Pictogramas / Símbolos Unicode
|
||||
if (/^(\p{Extended_Pictographic}|\p{Emoji_Presentation}|\s)+$/u.test(s)) return true;
|
||||
// Medidas, números, percentuais, frequências, consumos, intervalos
|
||||
if (/^[-+]?\d+([.,]\d+)?(\s*[%]|(\s*(MHz|GHz|KB|MB|GB|µA|mA|A|V|mV|mW|W|dBm|°C|mm|ohms|kΩ|µF|nF)))?$/i.test(s)) return true;
|
||||
if (/^[-+]?\d+([.,]\d+)?\s*(a|to|-)\s*[-+]?\d+([.,]\d+)?(\s*(MHz|GHz|KB|MB|GB|µA|mA|A|V|mV|mW|W|dBm|°C|mm|ohms|kΩ|µF|nF))?$/i.test(s)) return true;
|
||||
if (/^\d+\s*mW\s*\([^)]*\)$/i.test(s)) return true;
|
||||
// Hardware specs / contagem de periféricos: "1 (CAN 2.0, TWAI®)", "1 (Single-core)", "2 (Dual-core)"
|
||||
if (/^\d+(\s*\(.*\))?(\s*(\+|\/|,)\s*\d+(\s*\(.*\))?)*$/i.test(s)) return true;
|
||||
// Acrônimos de segurança / criptografia
|
||||
if (/^(AES|SHA|RSA|ECDSA|XTS|HMAC|ECC|Digital Signature|V\d+(\s*\(.*\))?)[A-Za-z0-9_/ -]*$/i.test(s)) return true;
|
||||
// Identificadores de módulos / produtos / encapsulamentos / pinos
|
||||
if (/^[A-Za-z0-9_+-]+(\/[A-Za-z0-9_.-]+)*(\^[0-9.]+)?$/.test(s)) return true;
|
||||
if (/^v\d+(\.\d+)*$/i.test(s)) return true;
|
||||
if (/^QFN\d+(\s*\(\d+×\d+\s*mm\))?$/i.test(s)) return true;
|
||||
// Lista de módulos / chips separados por vírgula ou +
|
||||
if (/^([A-Za-z0-9_().-]+(,\s*|\s*\+\s*|\s+))+[A-Za-z0-9_().-]+$/.test(s)) return true;
|
||||
// Cores hex
|
||||
if (/^#?[0-9a-fA-F]{3,8}$/.test(s)) return true;
|
||||
// Blocos de comandos e YAML
|
||||
if (s.startsWith("dependencies:") || s.startsWith("idf.py") || s.startsWith("pip install")) return true;
|
||||
// Sinais de hardware / categorias de pinos
|
||||
if (/^(USB|JTAG|UART|SPI|I2C|I2S|GPIO|MIPI|Low Power|Strapping|Digital|Analog|Power)[A-Za-z0-9_.+/ -]*$/i.test(s)) return true;
|
||||
// Nomes próprios de componentes / SDKs
|
||||
if (/^(ESP|ESP32|LVGL|TinyUSB|FreeRTOS|Rust|Arduino)[A-Za-z0-9_()./ -]+$/i.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Chaves específicas do livro (seção 7: apenas permitidas no pt)
|
||||
const BOOK_KEYS = new Set([
|
||||
"Header.book",
|
||||
@@ -152,6 +212,10 @@ function aviso(msg) {
|
||||
totalAvisos++;
|
||||
}
|
||||
|
||||
function info(msg) {
|
||||
console.log(` ${CORES.cinza}ℹ${CORES.reset} ${msg}`);
|
||||
}
|
||||
|
||||
/** Achata um objeto aninhado em chaves dot-notation */
|
||||
function achatarObjeto(obj, prefixo = "") {
|
||||
const resultado = {};
|
||||
@@ -170,7 +234,7 @@ function achatarObjeto(obj, prefixo = "") {
|
||||
// 1. Verificação da Base Canônica em Português (pt)
|
||||
// ---------------------------------------------------------------------------
|
||||
function verificarBasePt() {
|
||||
console.log(`\n${CORES.negrito}${CORES.ciano}[1/3] Verificação da Fonte Canônica (pt)${CORES.reset}`);
|
||||
console.log(`\n${CORES.negrito}${CORES.ciano}[1/4] Verificação da Fonte Canônica (pt)${CORES.reset}`);
|
||||
|
||||
// messages/pt.json
|
||||
const messagesPtPath = "messages/pt.json";
|
||||
@@ -232,10 +296,10 @@ function verificarBasePt() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Verificação de Catálogos e Dados de Outros Idiomas (quando presentes)
|
||||
// 2. Verificação de Catálogos & Chaves por Idioma
|
||||
// ---------------------------------------------------------------------------
|
||||
function verificarOutrosIdiomas(ptMessages) {
|
||||
console.log(`\n${CORES.negrito}${CORES.ciano}[2/3] Verificação de Catálogos & Chaves por Idioma${CORES.reset}`);
|
||||
console.log(`\n${CORES.negrito}${CORES.ciano}[2/4] Verificação de Catálogos & Chaves por Idioma${CORES.reset}`);
|
||||
|
||||
if (!ptMessages) return;
|
||||
const chavesPt = achatarObjeto(ptMessages);
|
||||
@@ -251,7 +315,7 @@ function verificarOutrosIdiomas(ptMessages) {
|
||||
if (isEnabled) {
|
||||
falha(`Catálogo '${loc}' (${pathMessages}) não encontrado para idioma ativo.`);
|
||||
} else {
|
||||
aviso(`Catálogo '${loc}' (${pathMessages}) ainda não criado (esperado para idioma inativo).`);
|
||||
info(`Catálogo '${loc}' (${pathMessages}) ainda não criado (idioma inativo).`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -282,9 +346,9 @@ function verificarOutrosIdiomas(ptMessages) {
|
||||
if (typeof vLoc === "string" && vLoc.trim() === "") {
|
||||
vazios++;
|
||||
} else if (vLoc === vPt && typeof vPt === "string") {
|
||||
const limpo = vPt.trim();
|
||||
if (!TECHNICAL_ALLOWLIST.has(limpo) && !/^\d+$/.test(limpo)) {
|
||||
if (!isTechnicalAllowlisted(vPt, k)) {
|
||||
iguaisSemAllowlist++;
|
||||
falha(`'${loc}': chave '${k}' tem valor idêntico ao pt fora da allowlist: "${vPt}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,19 +366,11 @@ function verificarOutrosIdiomas(ptMessages) {
|
||||
if (faltando > 0) falha(`'${loc}': ${faltando} chave(s) ausente(s) em relação ao pt.`);
|
||||
if (extras > 0) aviso(`'${loc}': ${extras} chave(s) excedente(s) em relação ao pt.`);
|
||||
if (vazios > 0) falha(`'${loc}': ${vazios} valor(es) vazio(s).`);
|
||||
if (iguaisSemAllowlist > 0) {
|
||||
aviso(`'${loc}': ${iguaisSemAllowlist} valor(es) idêntico(s) ao pt sem allowlist.`);
|
||||
}
|
||||
|
||||
if (faltando === 0 && vazios === 0) {
|
||||
sucesso(`Catálogo '${loc}' ativo possui paridade estrutural completa com 'pt'.`);
|
||||
if (faltando === 0 && vazios === 0 && iguaisSemAllowlist === 0) {
|
||||
sucesso(`Catálogo '${loc}' ativo possui paridade e tradução completas com 'pt'.`);
|
||||
}
|
||||
} else {
|
||||
// Idioma inativo: valida integridade dos arquivos existentes sem quebrar se estiver incompleto
|
||||
if (faltando > 0) aviso(`'${loc}' (inativo/em progresso): ${faltando} chave(s) ausente(s) em relação ao pt.`);
|
||||
if (extras > 0) aviso(`'${loc}' (inativo): ${extras} chave(s) excedente(s) em relação ao pt.`);
|
||||
if (vazios > 0) aviso(`'${loc}' (inativo): ${vazios} valor(es) vazio(s).`);
|
||||
|
||||
if (faltando === 0 && vazios === 0) {
|
||||
sucesso(`Catálogo '${loc}' (inativo) possui paridade estrutural completa com 'pt'.`);
|
||||
}
|
||||
@@ -322,6 +378,140 @@ function verificarOutrosIdiomas(ptMessages) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Verificação de Dados por Idioma (data/ e lib/data/)
|
||||
// ---------------------------------------------------------------------------
|
||||
function encontrarIdenticos(pt, en, path = "") {
|
||||
const problemas = [];
|
||||
if (typeof pt === "string" && typeof en === "string") {
|
||||
if (pt === en && !isTechnicalAllowlisted(pt, path)) {
|
||||
problemas.push({ path, value: pt });
|
||||
}
|
||||
} else if (Array.isArray(pt) && Array.isArray(en)) {
|
||||
for (let i = 0; i < Math.min(pt.length, en.length); i++) {
|
||||
problemas.push(...encontrarIdenticos(pt[i], en[i], `${path}[${i}]`));
|
||||
}
|
||||
} else if (typeof pt === "object" && pt !== null && typeof en === "object" && en !== null) {
|
||||
for (const k of Object.keys(en)) {
|
||||
if (k in pt) {
|
||||
problemas.push(...encontrarIdenticos(pt[k], en[k], path ? `${path}.${k}` : k));
|
||||
}
|
||||
}
|
||||
}
|
||||
return problemas;
|
||||
}
|
||||
|
||||
async function verificarDadosPorIdioma() {
|
||||
console.log(`\n${CORES.negrito}${CORES.ciano}[3/4] Verificação de Dados (data/ e lib/data/) por Idioma${CORES.reset}`);
|
||||
|
||||
for (const loc of ENABLED_LOCALES) {
|
||||
if (loc === "pt") continue;
|
||||
|
||||
// 1. data/<loc>/series.json vs data/pt/series.json
|
||||
const ptSeriesPath = "data/pt/series.json";
|
||||
const locSeriesPath = `data/${loc}/series.json`;
|
||||
if (existsSync(locSeriesPath)) {
|
||||
const ptSeries = JSON.parse(readFileSync(ptSeriesPath, "utf8"));
|
||||
const locSeries = JSON.parse(readFileSync(locSeriesPath, "utf8"));
|
||||
const problemas = encontrarIdenticos(ptSeries, locSeries, `data/${loc}/series.json`);
|
||||
if (problemas.length > 0) {
|
||||
falha(`'${loc}': ${problemas.length} valor(es) string em series.json idênticos ao pt fora da allowlist.`);
|
||||
for (const p of problemas.slice(0, 5)) {
|
||||
console.log(` ${CORES.cinza}${p.path}: "${p.value}"${CORES.reset}`);
|
||||
}
|
||||
} else {
|
||||
sucesso(`'${loc}': data/${loc}/series.json validado sem strings vazadas.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. data/<loc>/frameworks.json vs data/pt/frameworks.json
|
||||
const ptFwPath = "data/pt/frameworks.json";
|
||||
const locFwPath = `data/${loc}/frameworks.json`;
|
||||
if (existsSync(locFwPath)) {
|
||||
const ptFw = JSON.parse(readFileSync(ptFwPath, "utf8"));
|
||||
const locFw = JSON.parse(readFileSync(locFwPath, "utf8"));
|
||||
const problemas = encontrarIdenticos(ptFw, locFw, `data/${loc}/frameworks.json`);
|
||||
if (problemas.length > 0) {
|
||||
falha(`'${loc}': ${problemas.length} valor(es) string em frameworks.json idênticos ao pt fora da allowlist.`);
|
||||
for (const p of problemas.slice(0, 5)) {
|
||||
console.log(` ${CORES.cinza}${p.path}: "${p.value}"${CORES.reset}`);
|
||||
}
|
||||
} else {
|
||||
sucesso(`'${loc}': data/${loc}/frameworks.json validado sem strings vazadas.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. data/<loc>/conexoes/*.json vs public/conexoes/*.json
|
||||
const locConexoesDir = `data/${loc}/conexoes`;
|
||||
if (existsSync(locConexoesDir)) {
|
||||
let totalConexoesProblemas = 0;
|
||||
const arqs = readdirSync(locConexoesDir).filter((f) => f.endsWith(".json"));
|
||||
for (const f of arqs) {
|
||||
const ptPath = existsSync(`data/pt/conexoes/${f}`) ? `data/pt/conexoes/${f}` : `public/conexoes/${f}`;
|
||||
if (existsSync(ptPath)) {
|
||||
const pt = JSON.parse(readFileSync(ptPath, "utf8"));
|
||||
const en = JSON.parse(readFileSync(join(locConexoesDir, f), "utf8"));
|
||||
const problemas = encontrarIdenticos(pt, en, `conexoes/${f}`);
|
||||
totalConexoesProblemas += problemas.length;
|
||||
if (problemas.length > 0) {
|
||||
for (const p of problemas.slice(0, 3)) {
|
||||
console.log(` ${CORES.cinza}${p.path}: "${p.value}"${CORES.reset}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (totalConexoesProblemas > 0) {
|
||||
falha(`'${loc}': ${totalConexoesProblemas} valor(es) em conexoes idênticos ao pt fora da allowlist.`);
|
||||
} else {
|
||||
sucesso(`'${loc}': ${arqs.length} arquivos de conexoes validados sem strings vazadas.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. lib/data/<loc>/
|
||||
try {
|
||||
const ptComp = (await import("../lib/data/pt/componentes.js")).COMPONENTES_REGISTRY;
|
||||
const locComp = (await import(`../lib/data/${loc}/componentes.js`)).COMPONENTES_REGISTRY;
|
||||
const problemasComp = encontrarIdenticos(ptComp, locComp, `lib/data/${loc}/componentes.js`);
|
||||
if (problemasComp.length > 0) {
|
||||
falha(`'${loc}': ${problemasComp.length} string(s) em componentes.js idênticas ao pt fora da allowlist.`);
|
||||
for (const p of problemasComp.slice(0, 3)) {
|
||||
console.log(` ${CORES.cinza}${p.path}: "${p.value}"${CORES.reset}`);
|
||||
}
|
||||
} else {
|
||||
sucesso(`'${loc}': lib/data/${loc}/componentes.js validado sem strings vazadas.`);
|
||||
}
|
||||
} catch (e) {
|
||||
falha(`Erro ao validar lib/data/${loc}/componentes.js: ${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const ptDiag = (await import("../lib/data/pt/diagnostico.js")).ERROS_COMUNS;
|
||||
const locDiag = (await import(`../lib/data/${loc}/diagnostico.js`)).ERROS_COMUNS;
|
||||
const problemasDiag = encontrarIdenticos(ptDiag, locDiag, `lib/data/${loc}/diagnostico.js`);
|
||||
if (problemasDiag.length > 0) {
|
||||
falha(`'${loc}': ${problemasDiag.length} string(s) em diagnostico.js idênticas ao pt fora da allowlist.`);
|
||||
} else {
|
||||
sucesso(`'${loc}': lib/data/${loc}/diagnostico.js validado sem strings vazadas.`);
|
||||
}
|
||||
} catch (e) {
|
||||
falha(`Erro ao validar lib/data/${loc}/diagnostico.js: ${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const ptGloss = (await import("../lib/data/pt/glossario.js")).categoriasGlossario;
|
||||
const locGloss = (await import(`../lib/data/${loc}/glossario.js`)).categoriasGlossario;
|
||||
const problemasGloss = encontrarIdenticos(ptGloss, locGloss, `lib/data/${loc}/glossario.js`);
|
||||
if (problemasGloss.length > 0) {
|
||||
falha(`'${loc}': ${problemasGloss.length} string(s) em glossario.js idênticas ao pt fora da allowlist.`);
|
||||
} else {
|
||||
sucesso(`'${loc}': lib/data/${loc}/glossario.js validado sem strings vazadas.`);
|
||||
}
|
||||
} catch (e) {
|
||||
falha(`Erro ao validar lib/data/${loc}/glossario.js: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usos técnicos legítimos permitidos (seção 10: allowlist explícita)
|
||||
const TECHNICAL_BOOK_ALLOWLIST = [
|
||||
"https://esp-rs.github.io/book/",
|
||||
@@ -336,10 +526,10 @@ function sanitizarTextoParaLivro(conteudo) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Verificação de Regras do Livro (§7 da spec: ausência fora do pt)
|
||||
// 4. Verificação de Regras do Livro (§7 da spec: ausência fora do pt)
|
||||
// ---------------------------------------------------------------------------
|
||||
function verificarRegrasLivro() {
|
||||
console.log(`\n${CORES.negrito}${CORES.ciano}[3/3] Verificação Estrita de Referências ao Livro (Seção 7)${CORES.reset}`);
|
||||
console.log(`\n${CORES.negrito}${CORES.ciano}[4/4] Verificação Estrita de Referências ao Livro (Seção 7)${CORES.reset}`);
|
||||
|
||||
let violacoes = 0;
|
||||
|
||||
@@ -417,18 +607,22 @@ function verificarRegrasLivro() {
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
async function main() {
|
||||
console.log(`${CORES.negrito}${CORES.azul}======================================================${CORES.reset}`);
|
||||
console.log(`${CORES.negrito}${CORES.azul} ESPDocs · Verificação de Internacionalização (i18n) ${CORES.reset}`);
|
||||
console.log(`${CORES.negrito}${CORES.azul}======================================================${CORES.reset}`);
|
||||
|
||||
const ptMessages = verificarBasePt();
|
||||
verificarOutrosIdiomas(ptMessages);
|
||||
await verificarDadosPorIdioma();
|
||||
verificarRegrasLivro();
|
||||
|
||||
console.log(`\n${CORES.negrito}------------------------------------------------------${CORES.reset}`);
|
||||
if (totalErros === 0) {
|
||||
console.log(`${CORES.verde}${CORES.negrito}🎉 TODAS AS VERIFICAÇÕES DE I18N PASSARAM! (0 erros)${CORES.reset}\n`);
|
||||
if (totalErros === 0 && totalAvisos === 0) {
|
||||
console.log(`${CORES.verde}${CORES.negrito}🎉 TODAS AS VERIFICAÇÕES DE I18N PASSARAM! (0 erros, 0 avisos)${CORES.reset}\n`);
|
||||
return 0;
|
||||
} else if (totalErros === 0) {
|
||||
console.log(`${CORES.amarelo}${CORES.negrito}✔ Verificações passaram com ${totalAvisos} aviso(s).${CORES.reset}\n`);
|
||||
return 0;
|
||||
} else {
|
||||
console.log(`${CORES.vermelho}${CORES.negrito}✖ Foram encontrados ${totalErros} erro(s) de i18n que bloqueiam o aceite.${CORES.reset}\n`);
|
||||
@@ -436,4 +630,4 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
process.exitCode = main();
|
||||
process.exitCode = await main();
|
||||
Reference in new issue
Block a user