From cffce049e3ce0ee49df2fc150c2b5aec418fe47c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 22 Sep 2026 10:49:34 +0000 Subject: [PATCH] :globe_with_meridians: Multi-locale PO checker with word catalogs Split the checker engine from its word lists: ca/es catalogs now live in scripts/check-translations/words..txt and all messages are in English. Adds an es seed (calibrated to zero errors) and fixes 7 typos it found in es.po. Universal checks (placeholders, plurals, punctuation) run without a catalog. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/frontend/translations.md | 27 +- frontend/scripts/check-translations.js | 454 +++++++++------- .../scripts/check-translations/words.ca.txt | 505 ++++++++++++++++++ .../scripts/check-translations/words.es.txt | 321 +++++++++++ frontend/translations/es.po | 26 +- 5 files changed, 1103 insertions(+), 230 deletions(-) create mode 100644 frontend/scripts/check-translations/words.ca.txt create mode 100644 frontend/scripts/check-translations/words.es.txt diff --git a/.serena/memories/frontend/translations.md b/.serena/memories/frontend/translations.md index 1a0a67c59b..35bba082d7 100644 --- a/.serena/memories/frontend/translations.md +++ b/.serena/memories/frontend/translations.md @@ -58,25 +58,22 @@ high-coverage support reference, never the base. ## QA before commit - Run `node ./scripts/check-translations.js -l ` from - `frontend/` (also as `pnpm run check-translations` for `ca`): - 0 errors required; review warnings by hand. New valid words that - trip the gate go to `PARAULES_OK` in the script; `--self-test` - covers the detector rules. + `frontend/` (`pnpm run check-translations` covers `ca`): 0 errors + required; review warnings by hand. Word lists live in + `frontend/scripts/check-translations/words..txt` + (`[elision]` `[function]` `[common]` `[ok]` `[brands]`); new valid + words that trip the gate go to `[ok]`; `--self-test` covers the + detector rules. Without a catalog only the universal checks run. + `#, fuzzy` entries are skipped (known-pending, owned elsewhere). - Placeholder parity per entry (singular AND each plural form, also enforced by the script); verify `%s` against the `tr` call site when `en`/`es`/code disagree (a `%s` the code never passes renders literally; a dropped one swallows the argument). -- Glued words (AI batches drop spaces at wrap boundaries): tokenize - `msgstr` against a Catalan frequency list and review every - out-of-vocabulary token splittable as function-word + word - (`del'equip`, `lapolítica`, `sinecessiteu`), plus `,/.`/`:` without - following space, lowercase+Uppercase joins (`delPenpot`, - `oCapitalize`), `%s` glued to a word, and entries whose `ca` word - count is far below `en`. Known-valid splits, do NOT touch: - `compartides`, `edita`, `emplenament`, `desant`, `niar`, `negreta`, - `selector`, `atributs`, `sobreescriuran`, adverbs in `-ment`, - futures/participles (`desbloquegeu`, `predeterminat`, - `seleccionades`, `descarregueu`). +- Glued words (AI batches drop spaces at wrap boundaries): the + script flags function-word splits (`del'equip`, `lapolítica`, + `sinecessiteu`), `,/.`/`:` without following space, + lowercase+Uppercase joins (`delPenpot`, `oCapitalize`) and `%s` + glued to a word. - Balanced `[]`/`()` in markdown links; no double spaces; no glued words around `·`; trailing spaces match the source. - `git diff --stat` must touch only `frontend/translations/.po`. diff --git a/frontend/scripts/check-translations.js b/frontend/scripts/check-translations.js index 3cf43c5572..28d646401c 100644 --- a/frontend/scripts/check-translations.js +++ b/frontend/scripts/check-translations.js @@ -1,27 +1,24 @@ #!/usr/bin/env node -// QA per a fitxers PO de traduccions: detecta paraules enganxades per -// falta d'espai, placeholders trencats i estructures de plural perdudes. +// QA for PO translation files: finds words glued by a missing space, +// broken placeholders and lost plural structures. // -// Usage: +// Usage (from `frontend/`, like `translations.js`): // node ./scripts/check-translations.js [-l ] [--self-test] // -// Exit: 0 sense errors (els avisos no fallen), 1 amb errors, 2 mal us. -// Cal executar-ho des de `frontend/` (com `translations.js`). +// Exit: 0 with no errors (warnings don't fail), 1 with errors, +// 2 on misuse or unreadable files. +// +// Language data lives in `./scripts/check-translations/words..txt` +// (sections: [elision] [function] [common] [ok] [brands]). Without a +// catalog only the language-independent checks run (placeholders, +// plurals, punctuation, camelCase). import getopts from "getopts"; import { promises as fs } from "node:fs"; import gt from "gettext-parser"; -// Paraules funcionals curtes: si obren un token desconegut, gairebe -// segur que falta un espai (`del'equip`, `lapolitica`, `sinecessiteu`). -const FUNCIO = new Set( - "i o a e de del dels la el els les lo un una uns unes al als en amb per pel pels que com no ni si ja se es ho hi li me te ne em et us vos ens son són és més mes tot tota molt tan tant on quan perquè pero però doncs fins entre sobre sota cap cada altre seva seu meva teu nostre vostre aquest aquesta això allò jo tu ell ella nosaltres vosaltres ells".split( - " ", - ), -); - -// Marques que legitimen una unio minuscula+Majuscula. -const MARQUES_OK = [ +// Brands kept as-is in every locale. +const GENERIC_BRANDS = [ "GitHub", "GitLab", "YouTube", @@ -32,112 +29,96 @@ const MARQUES_OK = [ "macOS", ]; -// Vocabulari comu (verbs, noms, adjectius frequents) per a validar -// la part no-funcional d'una possible particio. Sense dependencia -// externa: llista curada a ma. Si falta una paraula, el cas caura a -// `avis` en comptes de `error`, mai en silenci. -const PARAULES_COMUNES = new Set( - "corregir poder obtenir mantindran targeta controladors importants futura donant tingui informar diversitat continuï juntament revocaran habilitar desament autenticació family aplicació còpies configurat necessita conflictes trigar res directament enviï correccions admet exportacions aplicacions càrrec envia enllaç actuals ajuda moment valor equips conjunt projecte fitxer compte usuari persona persones cosa temps part text nom contrasenya sessió idioma versió canvi arxiu error avís filtre cerca vista pestanya botó camp llista taula imatge forma capa fons color mida data correu propietari propietària membre opcions prova suprimireu interactuïn ajudarem quedi configurat permetre continueu breu manteniment present començar donant css font prioritzar suborganitzacions podreu poden facturació".split( - " ", - ), -); - -// Paraules correctes que el divisor parteix en dues parts valides -// (`segura` -> `segur` + `a`). Revisades a ma; si el checker es queixa -// d'una paraula correcta nova, afegiu-la aqui. -const PARAULES_OK = new Set( - "ajudarem aplana coincideixi coma comes comprova comprovar comprovar-ho comuna convidarem estarem existeixen existeixi fase gratuïta interna mateixa meves molta oberta obertes permeten permeti segura targeta teus usa usen niar negreta emplena emplenament edita selector atributs sobreescriuran compartides desenvolupadors administradors desbloquejar desbloquegeu opcionalment autoreferència especificant previsualització previsualitza predeterminat predeterminats predeterminada predefinides predefinida desactivades seleccionada seleccionades descarregueu centralitzat horitzontalment verticalment multijugador multiorganització complementari multiplicador actualitzarà activades activador cancel·laran comptaran configurar confirmar-ho conservaran desagrupa desbloqueja desenganxa despublica despublicar desselecciona-ho duplicades envia-ho importada importades interactius intercanvia lliscament lliuraran migracions mixte personalitzeu-les plantilles privadesa realitzant repositori restauraran selecciona-ho superposa transferiu unir-se usar-los autodesat demanar-ho desactivar-los abandonar-la accedir-hi accelereu activar-los actualitzeu adoptarà afegir-hi afegir-ne afegiu afegiu-ne agrupant-la ajudar-nos ajudar-vos ajudeu-nos ajudieu-nos ajustar-lo ajusteu ajustis alineació amplia ampliada amplieu animacions aplicant apliqueu arrossegueu assegurar-vos avançar-vos avisos baixades cancel·lada cancel·lant cercador coincidents col·laboració col·laborar commuteu compatibilitat concedir-hi consumeixin conèixer-vos definir-ne definiu depuració desbloca descarta-ho descriviu desemmascara deshabilita deshabilitada deshabilitades desplaçar desvincula dissenyeu editar-lo editen editeu eliminar-lo el·lipse el·lipses emplenats encabir-ho enfosqueix escriviu-nos espaiat espaiats especifiqueu estils expliqueu-nos exporta exportant exportar flexibilitat gaudireu il·limitat il·limitats il·lumina il·lustracions importats incrusta inhabilitada inicieu insereix inspecciona inspeccionar-ne instal·la instal·lació instal·lada instal·lades instal·lat instantànies integracions intentar-ho marca-ho migració milloreu milloreu-ho notificacions obsolet obteniu ometeu-ho omplir-ho opacitat orientar-vos personalitzada personalitzades pestanya porta-ho porta-retalls promoveu prototipar prototipatge publicar-la reassignar reassigneu remapejant remapeja remapejar silenciats sol·licitat sol·licitud sol·licituds suprimiran tipogràfic tipogràfica tipogràfics tipogràfiques torna-ho valorant vinculades vincular visiteu volteja unir-s'hi l'opacitat l'espaiat l'interlineat explora'n edita'l desant aquesta aquestes baixa capa capes compartir comproveu desa deseu dreta esteu files fixa inicia inicials meus només nous noves pel perfil perquè pes pla seus totes treballo una vosaltres vàlida vàlides desactiva desactivat desactivada desconnecta descobreix connectades alguna canvia compost comprovant del dels des enviarem mixtes noms quina senzilla uneix ves vista vés desconegut fixa pel pels tota comentari comunitat set desvinculat separades".split( - " ", - ), -); - const TOKEN_RE = /[\p{L}\p{M}]+(?:[·'’\-][\p{L}\p{M}]+)*/gu; const SKIP_RE = /[%{@/\\=<>|#0-9]/; const PLACEHOLDER_RES = [/%[sd]/g, /\{[^}]*\}/g, /%\([^)]*\)[sd]/g]; -const PUNT_RE = /[,.:;!?…»)\]]([A-Za-zÀ-Úà-ú«("“‘$])/gu; +const PUNCT_RE = /[,.:;!?…»)\]]([A-Za-zÀ-Úà-ú«("“‘$])/gu; const CAMEL_RE = /[a-zàèéíòóúüç·]([A-ZÀÈÉÍÒÓÚÜ][a-zàèéíòóúü]+)/gu; const PH_GLUED_RE = /%[sd](?=[A-Za-zÀ-Úà-ú])/gu; +const APOSTROPHE_DIGIT_RE = /[a-zàèéíòóúüç·]d['’][0-9]/gu; -function tokenitza(text) { +function tokenize(text) { return [...text.matchAll(TOKEN_RE)].map((m) => m[0].toLowerCase()); } -function baseApostrof(token) { - const m = token.match(/^[ldsmntc]['’](.+)$/); +function elisionBase(token, elision) { + if (!elision) return null; + const m = token.match(new RegExp(`^[${elision}]['’](.+)$`)); return m ? m[1] : null; } -function compte(text, re) { +function countIn(text, re) { re.lastIndex = 0; return [...text.matchAll(re)].length; } -// Validesa d'una PART d'una particio (mai la paraula sencera: un -// enganxat repetit no s'ha d'auto-validar perque surt molt). -function partValida(pal, freq) { +// A word is valid as one half of a split. Never the whole token: +// a repeated glue (`del'equip` x3) must not validate itself by frequency. +function validPart(word, words, freq) { return ( - FUNCIO.has(pal) || - PARAULES_OK.has(pal) || - PARAULES_COMUNES.has(pal) || - (freq.get(pal) ?? 0) >= 1 + words.functionWords.has(word) || + words.okWords.has(word) || + words.commonWords.has(word) || + (freq.get(word) ?? 0) >= 1 ); } -// Totes les particions (esq, dre, regla) d'un token, incloent-hi -// cada segment separat per guionet. -function* particions(tok, freq) { +// Every (left, right, rule) split of a token, including each +// hyphen-separated segment. +function* splits(tok, words, freq) { const cands = [tok, ...tok.split("-")]; - const vistos = new Set(); + const seen = new Set(); for (const c of cands) { if (c.length < 3) continue; for (let k = 1; k < c.length; k++) { - const esq = c.slice(0, k); - const dre = c.slice(k); - if (esq.length < 1) continue; - if (dre.length < 2 && !FUNCIO.has(dre)) continue; - const clau = esq + "|" + dre; - if (vistos.has(clau)) continue; - vistos.add(clau); - const dreBase = baseApostrof(dre) ?? dre; - const dreOk = partValida(dreBase, freq); - const esqOk = partValida(esq, freq); - if (FUNCIO.has(esq) && dreOk) yield [esq, dre, "func-esq"]; - else if (FUNCIO.has(dre) && dre.length <= 4 && esqOk) - yield [esq, dre, "func-dre"]; + const left = c.slice(0, k); + const right = c.slice(k); + if (left.length < 1) continue; + if (right.length < 2 && !words.functionWords.has(right)) continue; + const key = left + "|" + right; + if (seen.has(key)) continue; + seen.add(key); + const rightBase = elisionBase(right, words.elision) ?? right; + const rightOk = validPart(rightBase, words, freq); + const leftOk = validPart(left, words, freq); + if (words.functionWords.has(left) && rightOk) + yield [left, right, "func-left"]; + else if (words.functionWords.has(right) && right.length <= 4 && leftOk) + yield [left, right, "func-right"]; else if ( c.length >= 8 && - esq.length >= 3 && - dre.length >= 2 && - esqOk && - dreOk + left.length >= 3 && + right.length >= 2 && + leftOk && + rightOk ) - yield [esq, dre, "contingut"]; + yield [left, right, "content"]; } } } -const ORDRE = { "func-esq": 0, "func-dre": 1, contingut: 2 }; +const RULE_ORDER = { "func-left": 0, "func-right": 1, content: 2 }; -function millorParticio(tok, freq) { - let millor = null; - for (const [esq, dre, regla] of particions(tok, freq)) { - if (esq.includes("-")) continue; +function bestSplit(tok, words, freq) { + let best = null; + for (const [left, right, rule] of splits(tok, words, freq)) { + if (left.includes("-")) continue; if ( - !millor || - ORDRE[regla] < ORDRE[millor[2]] || - (ORDRE[regla] === ORDRE[millor[2]] && esq.length > millor[0].length) + !best || + RULE_ORDER[rule] < RULE_ORDER[best[2]] || + (RULE_ORDER[rule] === RULE_ORDER[best[2]] && left.length > best[0].length) ) { - millor = [esq, dre, regla]; + best = [left, right, rule]; } } - return millor; + return best; } -function plega(s) { +function folded(s) { return s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase(); } -function distancia(a, b) { +function distance(a, b) { const dp = Array.from({ length: a.length + 1 }, (_, i) => [i]); for (let j = 1; j <= b.length; j++) dp[0][j] = j; for (let i = 1; i <= a.length; i++) { @@ -152,27 +133,27 @@ function distancia(a, b) { return dp[a.length][b.length]; } -// Semblança amb l'original: una paraula correcta gairebe sempre -// s'assembla al seu cognat en `es`/`en`; un enganxat, mai. Un afix -// pur (1-4 lletres de mes o de menys) no compta: es justament la -// forma de l'enganxat (`lapolitica` vs `politica`, `desdel` vs `desde`). -function esCognat(tok, refToks) { - const base = tok.replace(/^[ldsmntc]['’]/, ""); - const t = plega(base); - const llindar = t.length <= 4 ? 0 : 1; +// Resemblance to the source: a correct word almost always resembles +// its cognate in `en`/`es`; a glued one never does. A pure affix +// (1-4 letters more or less) doesn't count: that is exactly the +// shape of a glue (`lapolitica` vs `politica`, `desdel` vs `desde`). +function isCognate(tok, refToks, elision) { + const base = tok.replace(new RegExp(`^[${elision || "-"}]['’]`), ""); + const t = folded(base); + const limit = t.length <= 4 ? 0 : 1; for (const r of refToks) { - const rt = plega(r); - if (Math.abs(rt.length - t.length) > Math.max(llindar, 4)) continue; - const d = distancia(t, rt); + const rt = folded(r); + if (Math.abs(rt.length - t.length) > Math.max(limit, 4)) continue; + const d = distance(t, rt); if (d === 0) return true; - if (d > llindar) continue; - if (esAfix(t, rt)) continue; + if (d > limit) continue; + if (isAffix(t, rt)) continue; return true; } return false; } -function esAfix(t, rt) { +function isAffix(t, rt) { const dif = Math.abs(t.length - rt.length); if (dif < 1 || dif > 4) return false; return ( @@ -180,135 +161,188 @@ function esAfix(t, rt) { ); } -function refToks(textEn, textEs) { +function refToks(...texts) { const toks = new Set(); - for (const t of tokenitza(textEn)) toks.add(t); - for (const t of tokenitza(textEs)) toks.add(t); + for (const text of texts) for (const t of tokenize(text)) toks.add(t); return toks; } -function contextNet(frag) { +function cleanContext(frag) { return !/https?:|www\.|@|\|target:|\.mcp\.json/.test(frag); } -function revisaPuntuacio(text) { - const trobats = []; - for (const m of text.matchAll(PUNT_RE)) { - const punt = m[0][0]; - const seg = m[1]; +function checkPunctuation(text, brands) { + const found = []; + for (const m of text.matchAll(PUNCT_RE)) { + const punct = m[0][0]; + const next = m[1]; const frag = text.slice(Math.max(0, m.index - 30), m.index + 32); - if (!contextNet(frag)) continue; - if (m[0] === "](") continue; // enllaç markdown + if (!cleanContext(frag)) continue; + if (m[0] === "](") continue; // markdown link if (m[0] === ":s" && frag.includes("|target:")) continue; // [x|target:self] - if (/%[sd]\.\(/.test(frag)) continue; // notació tècnica %s.(sufix)... - if (punt === "." && !/[A-ZÀÈÉÍÒÓÚÜ«"“(%$]/.test(seg)) continue; - trobats.push({ que: m[0], frag }); + if (/%[sd]\.\(/.test(frag)) continue; // %s.(suffix)... notation + if (punct === "." && !/[A-ZÀÈÉÍÒÓÚÜ«"“(%$]/.test(next)) continue; + found.push({ what: m[0], frag }); } for (const m of text.matchAll(CAMEL_RE)) { const frag = text.slice(Math.max(0, m.index - 30), m.index + 32); - if (MARQUES_OK.some((mk) => frag.includes(mk))) continue; - trobats.push({ que: m[0], frag }); + if (brands.some((mk) => frag.includes(mk))) continue; + found.push({ what: m[0], frag }); } for (const m of text.matchAll(PH_GLUED_RE)) { const frag = text.slice(Math.max(0, m.index - 30), m.index + 32); - trobats.push({ que: m[0], frag }); + found.push({ what: m[0], frag }); } - for (const m of text.matchAll(/[a-zàèéíòóúüç·]d['’][0-9]/gu)) { + for (const m of text.matchAll(APOSTROPHE_DIGIT_RE)) { const frag = text.slice(Math.max(0, m.index - 30), m.index + 32); - trobats.push({ que: m[0], frag }); + found.push({ what: m[0], frag }); } - return trobats; + return found; } -function carrega(ruta) { - return fs.readFile(ruta).then((buf) => gt.po.parse(buf, "utf-8")); +function loadFile(path) { + return fs.readFile(path).then((buf) => gt.po.parse(buf, "utf-8")); } -async function revisa(locale) { - const base = `./translations/${locale}.po`; - const [dades, dadesEn, dadesEs] = await Promise.all([ - carrega(base), - carrega("./translations/en.po"), - carrega("./translations/es.po").catch(() => null), +function parseCatalog(text, locale) { + const words = { + functionWords: new Set(), + commonWords: new Set(), + okWords: new Set(), + brands: [], + elision: "", + }; + const sections = { function: 1, common: 1, ok: 1, brands: 1, elision: 1 }; + let current = null; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + const sec = line.match(/^\[([a-z]+)\]$/); + if (sec) { + if (!sections[sec[1]]) { + throw new Error(`words.${locale}.txt: unknown section [${sec[1]}]`); + } + current = sec[1]; + continue; + } + if (!current) { + throw new Error(`words.${locale}.txt: word outside any section`); + } + if (current === "elision") { + if (!/^[a-z]+$/.test(line)) { + throw new Error(`words.${locale}.txt: bad [elision] line`); + } + words.elision += line; + } else if (current === "brands") { + words.brands.push(line); + } else { + words[`${current}Words`].add(line.toLowerCase()); + } + } + return words; +} + +async function loadCatalog(locale) { + const path = `./scripts/check-translations/words.${locale}.txt`; + try { + return parseCatalog(await fs.readFile(path, "utf-8"), locale); + } catch (err) { + if (err.code === "ENOENT") return null; + throw err; + } +} + +function isFuzzy(entry) { + return (entry.comments?.flag ?? "").split(/,\s*/).includes("fuzzy"); +} + +async function check(locale, words) { + const [data, dataEn, dataEs] = await Promise.all([ + loadFile(`./translations/${locale}.po`), + loadFile("./translations/en.po"), + locale === "es" ? null : loadFile("./translations/es.po").catch(() => null), ]); - const entrades = dades.translations[""]; - const entradesEn = dadesEn.translations[""]; - const entradesEs = dadesEs ? dadesEs.translations[""] : {}; + const entries = data.translations[""]; + const entriesEn = dataEn.translations[""]; + const entriesEs = dataEs ? dataEs.translations[""] : {}; + const brands = [...GENERIC_BRANDS, ...(words?.brands ?? [])]; const errors = []; - const avisos = []; + const warnings = []; const freq = new Map(); - for (const [msgid, e] of Object.entries(entrades)) { - if (msgid === "") continue; + for (const [msgid, e] of Object.entries(entries)) { + if (msgid === "" || isFuzzy(e)) continue; for (const t of e.msgstr) - for (const w of tokenitza(t)) { + for (const w of tokenize(t)) { freq.set(w, (freq.get(w) ?? 0) + 1); } } - for (const [msgid, e] of Object.entries(entrades)) { - if (msgid === "") continue; - const textos = e.msgstr; - const eEn = entradesEn[msgid]; - const textosEn = eEn ? eEn.msgstr : []; - const eEs = entradesEs[msgid]; - const textosEs = eEs ? eEs.msgstr : []; + for (const [msgid, e] of Object.entries(entries)) { + if (msgid === "" || isFuzzy(e)) continue; + const texts = e.msgstr; + const eEn = entriesEn[msgid]; + const textsEn = eEn ? eEn.msgstr : []; + const eEs = entriesEs[msgid]; + const textsEs = eEs ? eEs.msgstr : []; if (eEn?.msgid_plural && !e.msgid_plural) { errors.push( - `${msgid}: l'original usa msgid_plural pero el ${locale} no te msgstr[0]/[1]`, + `${msgid}: source uses msgid_plural but ${locale} lacks msgstr[0]/[1]`, ); } - textos.forEach((text, i) => { + texts.forEach((text, i) => { if (!text) return; - const textEn = textosEn[i] ?? ""; - const textEs = textosEs[i] ?? ""; + const textEn = textsEn[i] ?? ""; + const textEs = textsEs[i] ?? ""; const refs = refToks(textEn, textEs); if (textEn) { for (const re of PLACEHOLDER_RES) { - const nEn = compte(textEn, re); - const nCa = compte(text, re); - if (nEn !== nCa) { + const nEn = countIn(textEn, re); + const nLoc = countIn(text, re); + if (nEn !== nLoc) { errors.push( - `${msgid}[${i}]: placeholders ${re.source}: en=${nEn} ${locale}=${nCa}`, + `${msgid}[${i}]: placeholder mismatch ${re.source}: en=${nEn} ${locale}=${nLoc}`, ); } } } - for (const t of revisaPuntuacio(text)) { + for (const t of checkPunctuation(text, brands)) { errors.push( - `${msgid}: puntuacio enganxada ${JSON.stringify(t.que)} ...${t.frag}...`, + `${msgid}: glued punctuation ${JSON.stringify(t.what)} ...${t.frag}...`, ); } - for (const tok of tokenitza(text)) { + if (!words) return; + for (const tok of tokenize(text)) { if (SKIP_RE.test(tok)) continue; - if (PARAULES_OK.has(tok)) continue; - // Sense salt per frequencia del token sencer: un enganxat - // repetit (`del'equip` x3) no s'ha d'auto-validar mai. - const base = baseApostrof(tok); - if (base && partValida(base, freq)) continue; - if (esCognat(tok, refs)) continue; - const part = millorParticio(tok, freq); + if (words.okWords.has(tok)) continue; + // No whole-token frequency skip: a repeated glue + // (`del'equip` x3) must never validate itself by frequency. + const base = elisionBase(tok, words.elision); + if (base && validPart(base, words, freq)) continue; + if (isCognate(tok, refs, words.elision)) continue; + const part = bestSplit(tok, words, freq); if (!part) continue; - const [esq, dre, regla] = part; - const linia = `${msgid}: possible enganxat ${JSON.stringify(tok)} -> ${esq} + ${dre}`; - if (regla === "contingut") avisos.push(`${linia} (revisar)`); - else errors.push(linia); + const [left, right, rule] = part; + const line = `${msgid}: glued word ${JSON.stringify(tok)} -> ${left} + ${right}`; + if (rule === "content") warnings.push(`${line} (review)`); + else errors.push(line); } }); } - return { errors, avisos }; + return { errors, warnings }; } const FIXTURES = [ - // [text, esperaError] + // [text, expectsError] ["Els membres del'equip continuaran.", true], ["Accepteu lapolítica de privadesa.", true], ["Si necessiteu més informació,contacteu amb nosaltres.", true], ["Revisions delPenpot disponibles.", true], ["Seleccioneu Lowercase oCapitalize.", true], ["Cobreix fins a %seditors nous.", true], + ["Bienvenido acasa nueva.", true], ["Les biblioteques compartides.", false], ["Edita el webhook.", false], ["Emplenament del grup.", false], @@ -318,71 +352,87 @@ const FIXTURES = [ ["Desbloquegeu les funcions.", false], ["Atributs SVG importats.", false], ["Commuta la negreta.", false], + ["Bibliotecas compartidas.", false], ]; -async function selfTest() { - // Vocabulari minim per a les fixtures. +async function selfTest(catalog) { + // Minimal vocabulary for the fixtures. const freq = new Map( - "els membres continuaran accepteu de privadesa si necessiteu més informació amb nosaltres revisions disponibles seleccioneu lowercase infrequent les biblioteques compartides edita el webhook emplenament del grup està desant fitxer components no es poden niar gira commuta la negreta desbloquegeu les funcions atributs svg importats horitzontalment podeu crear equip política" + "els membres continuaran accepteu de privadesa si necessiteu més informació amb nosaltres revisions disponibles seleccioneu lowercase infrequent les biblioteques compartides edita el webhook emplenament del grup està desant fitxer components no es poden niar gira commuta la negreta desbloquegeu les funcions atributs svg importats horitzontalment podeu crear equip política casa bibliotecas compartidas bienvenido nueva" .split(" ") .map((w) => [w, 3]), ); - let mal = 0; - for (const [text, esperaError] of FIXTURES) { - const trobats = []; - for (const t of revisaPuntuacio(text)) trobats.push(`punt:${t.que}`); - for (const tok of tokenitza(text)) { - if (SKIP_RE.test(tok) || FUNCIO.has(tok)) continue; - if (PARAULES_OK.has(tok)) continue; - if (partValida(tok, freq)) continue; - const base = baseApostrof(tok); - if (base && partValida(base, freq)) continue; - const part = millorParticio(tok, freq); - if (part && part[2] !== "contingut") trobats.push(`tok:${tok}`); + const words = { + functionWords: catalog.functionWords, + commonWords: new Set(), + okWords: new Set(), + brands: [], + elision: catalog.elision, + }; + let bad = 0; + for (const [text, expectsError] of FIXTURES) { + const found = []; + for (const t of checkPunctuation(text, GENERIC_BRANDS)) + found.push(`punct:${t.what}`); + for (const tok of tokenize(text)) { + if (SKIP_RE.test(tok) || words.functionWords.has(tok)) continue; + if (words.okWords.has(tok)) continue; + const base = elisionBase(tok, words.elision); + if (base && validPart(base, words, freq)) continue; + const part = bestSplit(tok, words, freq); + if (part && part[2] !== "content") found.push(`tok:${tok}`); } - const hiHaError = trobats.length > 0; - if (hiHaError !== esperaError) { + if (found.length > 0 !== expectsError) { console.error( - `SELF-TEST FALLA: ${JSON.stringify(text)} esperava error=${esperaError}, trobats=${JSON.stringify(trobats)}`, + `SELF-TEST FAILED: ${JSON.stringify(text)} expected error=${expectsError}, found=${JSON.stringify(found)}`, ); - mal++; + bad++; } } - if (mal > 0) process.exit(1); - console.log(`SELF-TEST OK: ${FIXTURES.length} casos`); + if (bad > 0) process.exit(1); + console.log(`SELF-TEST OK: ${FIXTURES.length} cases`); } -const opcions = getopts(process.argv.slice(2), { +const options = getopts(process.argv.slice(2), { string: ["l"], boolean: ["self-test", "h"], alias: { locale: ["l"], help: ["h"] }, }); -if (opcions.h) { - console.log(`QA de traduccions PO. -Us: node ./scripts/check-translations.js [-l ] [--self-test] - -l: locale a revisar (defecte: ca), des de frontend/ - --self-test: comprova el detector amb casos integrats`); +if (options.h) { + console.log(`PO translation QA. +Usage: node ./scripts/check-translations.js [-l ] [--self-test] + -l: locale to check (default: ca), from frontend/ + --self-test: validate the detector with built-in cases`); process.exit(0); } -if (opcions["self-test"]) { - await selfTest(); -} else { - const locale = opcions.l ?? opcions.locale ?? "ca"; - let dades; - try { - dades = await revisa(locale); - } catch (err) { - console.error( - `No s'ha pogut llegir translations/${locale}.po: ${err.message}`, - ); +if (options["self-test"]) { + const catalog = await loadCatalog("ca"); + if (!catalog) { + console.error("SELF-TEST FAILED: cannot load words.ca.txt"); process.exit(2); } - for (const w of dades.avisos) console.log(`avis: ${w}`); - for (const e of dades.errors) console.error(`error: ${e}`); + await selfTest(catalog); +} else { + const locale = options.l ?? options.locale ?? "ca"; + const catalog = await loadCatalog(locale); + if (!catalog) { + console.log( + `note: no word catalog for '${locale}', lexical checks skipped`, + ); + } + let result; + try { + result = await check(locale, catalog); + } catch (err) { + console.error(`Could not read translations/${locale}.po: ${err.message}`); + process.exit(2); + } + for (const w of result.warnings) console.log(`warn: ${w}`); + for (const e of result.errors) console.error(`error: ${e}`); console.log( - `${locale}: ${dades.errors.length} errors, ${dades.avisos.length} avisos`, + `${locale}: ${result.errors.length} errors, ${result.warnings.length} warnings`, ); - process.exit(dades.errors.length > 0 ? 1 : 0); + process.exit(result.errors.length > 0 ? 1 : 0); } diff --git a/frontend/scripts/check-translations/words.ca.txt b/frontend/scripts/check-translations/words.ca.txt new file mode 100644 index 0000000000..54fc569dc4 --- /dev/null +++ b/frontend/scripts/check-translations/words.ca.txt @@ -0,0 +1,505 @@ +# Word catalog for check-translations.js (Catalan). +# Comment lines start with #. Blank lines are ignored. +# [function] short function words; a token starting with one of +# these is likely missing a space (del-equip style). +# [common] ordinary words used to judge the other half of a split. +# [ok] correct words the splitter would flag; never report +# these. Add new valid words here when the gate trips. +# [brands] extra locale-specific brands/terms kept as-is. +# [elision] letters elided with an apostrophe. + +[elision] +ldsmntc + +[function] +i +o +a +e +de +del +dels +la +el +els +les +lo +un +una +uns +unes +al +als +en +amb +per +pel +pels +que +com +no +ni +si +ja +se +es +ho +hi +li +me +te +ne +em +et +us +vos +ens +son +són +és +més +mes +tot +tota +molt +tan +tant +on +quan +perquè +pero +però +doncs +fins +entre +sobre +sota +cap +cada +altre +seva +seu +meva +teu +nostre +vostre +aquest +aquesta +això +allò +jo +tu +ell +ella +nosaltres +vosaltres +ells + +[common] +corregir +poder +obtenir +mantindran +targeta +controladors +importants +futura +donant +tingui +informar +diversitat +continuï +juntament +revocaran +habilitar +desament +autenticació +family +aplicació +còpies +configurat +necessita +conflictes +trigar +res +directament +enviï +correccions +admet +exportacions +aplicacions +càrrec +envia +enllaç +actuals +ajuda +moment +valor +equips +conjunt +projecte +fitxer +compte +usuari +persona +persones +cosa +temps +part +text +nom +contrasenya +sessió +idioma +versió +canvi +arxiu +error +avís +filtre +cerca +vista +pestanya +botó +camp +llista +taula +imatge +forma +capa +fons +color +mida +data +correu +propietari +propietària +membre +opcions +prova +suprimireu +interactuïn +ajudarem +quedi +configurat +permetre +continueu +breu +manteniment +present +començar +donant +css +font +prioritzar +suborganitzacions +podreu +poden +facturació + +[ok] +ajudarem +aplana +coincideixi +coma +comes +comprova +comprovar +comprovar-ho +comuna +convidarem +estarem +existeixen +existeixi +fase +gratuïta +interna +mateixa +meves +molta +oberta +obertes +permeten +permeti +segura +targeta +teus +usa +usen +niar +negreta +emplena +emplenament +edita +selector +atributs +sobreescriuran +compartides +desenvolupadors +administradors +desbloquejar +desbloquegeu +opcionalment +autoreferència +especificant +previsualització +previsualitza +predeterminat +predeterminats +predeterminada +predefinides +predefinida +desactivades +seleccionada +seleccionades +descarregueu +centralitzat +horitzontalment +verticalment +multijugador +multiorganització +complementari +multiplicador +actualitzarà +activades +activador +cancel·laran +comptaran +configurar +confirmar-ho +conservaran +desagrupa +desbloqueja +desenganxa +despublica +despublicar +desselecciona-ho +duplicades +envia-ho +importada +importades +interactius +intercanvia +lliscament +lliuraran +migracions +mixte +personalitzeu-les +plantilles +privadesa +realitzant +repositori +restauraran +selecciona-ho +superposa +transferiu +unir-se +usar-los +autodesat +demanar-ho +desactivar-los +abandonar-la +accedir-hi +accelereu +activar-los +actualitzeu +adoptarà +afegir-hi +afegir-ne +afegiu +afegiu-ne +agrupant-la +ajudar-nos +ajudar-vos +ajudeu-nos +ajudieu-nos +ajustar-lo +ajusteu +ajustis +alineació +amplia +ampliada +amplieu +animacions +aplicant +apliqueu +arrossegueu +assegurar-vos +avançar-vos +avisos +baixades +cancel·lada +cancel·lant +cercador +coincidents +col·laboració +col·laborar +commuteu +compatibilitat +concedir-hi +consumeixin +conèixer-vos +definir-ne +definiu +depuració +desbloca +descarta-ho +descriviu +desemmascara +deshabilita +deshabilitada +deshabilitades +desplaçar +desvincula +dissenyeu +editar-lo +editen +editeu +eliminar-lo +el·lipse +el·lipses +emplenats +encabir-ho +enfosqueix +escriviu-nos +espaiat +espaiats +especifiqueu +estils +expliqueu-nos +exporta +exportant +exportar +flexibilitat +gaudireu +il·limitat +il·limitats +il·lumina +il·lustracions +importats +incrusta +inhabilitada +inicieu +insereix +inspecciona +inspeccionar-ne +instal·la +instal·lació +instal·lada +instal·lades +instal·lat +instantànies +integracions +intentar-ho +marca-ho +migració +milloreu +milloreu-ho +notificacions +obsolet +obteniu +ometeu-ho +omplir-ho +opacitat +orientar-vos +personalitzada +personalitzades +pestanya +porta-ho +porta-retalls +promoveu +prototipar +prototipatge +publicar-la +reassignar +reassigneu +remapejant +remapeja +remapejar +silenciats +sol·licitat +sol·licitud +sol·licituds +suprimiran +tipogràfic +tipogràfica +tipogràfics +tipogràfiques +torna-ho +valorant +vinculades +vincular +visiteu +volteja +unir-s'hi +l'opacitat +l'espaiat +l'interlineat +explora'n +edita'l +desant +aquesta +aquestes +baixa +capa +capes +compartir +comproveu +desa +deseu +dreta +esteu +files +fixa +inicia +inicials +meus +només +nous +noves +pel +perfil +perquè +pes +pla +seus +totes +treballo +una +vosaltres +vàlida +vàlides +desactiva +desactivat +desactivada +desconnecta +descobreix +connectades +alguna +canvia +compost +comprovant +del +dels +des +enviarem +mixtes +noms +quina +senzilla +uneix +ves +vista +vés +desconegut +fixa +pel +pels +tota +comentari +comunitat +set +desvinculat +separades + +[brands] diff --git a/frontend/scripts/check-translations/words.es.txt b/frontend/scripts/check-translations/words.es.txt new file mode 100644 index 0000000000..10a6356517 --- /dev/null +++ b/frontend/scripts/check-translations/words.es.txt @@ -0,0 +1,321 @@ +# Word catalog for check-translations.js (Spanish). +# Comment lines start with #. Blank lines are ignored. +# [function] short function words; a token starting with one of +# these is likely missing a space. +# [common] ordinary words used to judge the other half of a split. +# [ok] correct words the splitter would flag; never report +# these. Add new valid words here when the gate trips. +# [brands] extra locale-specific brands/terms kept as-is. +# [elision] letters elided with an apostrophe (Spanish has none). + +[elision] + +[function] +el +la +los +las +un +una +unos +unas +al +del +de +en +y +e +o +u +que +como +con +por +para +sin +sobre +entre +hasta +desde +donde +cuando +porque +pues +si +no +ni +mas +más +muy +tan +tanto +todo +todos +toda +todas +cada +otro +otra +otros +otras +este +esta +estos +estas +ese +esa +eso +aquel +aquella +aquello +mi +mis +tu +tus +su +sus +nuestro +nuestra +nuestros +nuestras +vuestro +vuestra +vuestros +vuestras +me +te +se +nos +os +le +les +lo +yo +tú +él +ella +ello +nosotros +vosotros +ellos +ellas +esto +eso +aquello +algo +alguien +nada +nadie +quien +quienes +cual +cuales +cuanto +cuanta +cuantos +cuantas + +[common] +habilitar +crear +eliminar +guardar +abrir +cerrar +enviar +compartir +invitar +mover +copiar +pegar +subir +bajar +editar +configurar +activar +desactivar +permitir +necesitar +poder +deber +querer +saber +hacer +poner +salir +entrar +volver +llegar +pasar +quedar +dar +estar +ser +haber +tener +ir +ver +usar +buscar +cambiar +seguir +encontrar +mostrar +ocultar +añadir +quitar +seleccionar +aplicar +cancelar +aceptar +probar +comprobar +generar +descargar +equipo +archivo +proyecto +cuenta +nombre +correo +error +aviso +ventana +página +botón +campo +lista +tabla +imagen +forma +capa +color +tamaño +fecha +enlace +nuevo +nueva +grande +pequeño +bueno +actual +siguiente +anterior +mismo +otra +primer +último +único +libre +público + +[ok] +abandonarla +abrirlo +activarlos +actuales +actualizada +adicionales +administradores +algunos +alto +aplicarse +asignarse +aun +aunque +autoguardada +autoguardado +autoreferencia +ayudarnos +ayudarte +borradores +borrarlo +cambiada +caracteres +como +comparte +compartir +comprobar +comprueba +compuesto +comunidad +conectarlo +conectarse +conjunto +conocerte +consigue +contactarnos +contener +contiene +contexto +danos +demás +desactivarlos +deseleccionar +desuscripción +durante +editores +eje +eliminarlo +eliminarlos +ella +eso +esos +este +estoy +estándar +expirada +funcionalidades +granulares +hay +hubo +importada +iniciarse +invitaciones +lectores +letras +manifiesto +menos +nosotros +otras +pero +personales +peso +primero +principales +propiedades +proveedores +publicarla +publicarlo +puesto +regenerada +restaurarlo +restaurarlos +seleccionada +selector +servidores +sino +solicitudes +soportada +sufijo +tanto +temas +tenerte +tipográfico +tono +unidades +unir +unirse +uno +unos +usarlas +utilizada +verla +versiones +verticales +vincularse +visuales +volverlo +porque + +[brands] diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 4248c3729c..e868e2383b 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -112,7 +112,7 @@ msgstr "La contraseña ha sido cambiada" #: src/app/main/ui/auth/recovery_request.cljs:50 msgid "auth.notifications.profile-not-verified" msgstr "" -"El perfil aun no ha sido verificado, por favor valida el perfil antes de " +"El perfil aún no ha sido verificado, por favor valida el perfil antes de " "continuar." #: src/app/main/ui/auth/recovery_request.cljs:33 @@ -196,8 +196,8 @@ msgstr "Términos de servicio" #, unused msgid "auth.terms-privacy-agreement" msgstr "" -"Al crear una nueva cuenta, aceptas nuestros [términos de servicio](%s) y " -"[política de privacidad](%s)." +"Al crear una nueva cuenta, aceptas nuestros términos de servicio y política " +"de privacidad." #: src/app/main/ui/auth/register.cljs:238 #, unused @@ -1790,7 +1790,7 @@ msgstr "Parece que no es una imagen válida." msgid "errors.member-is-muted" msgstr "" "El perfil que esta invitando tiene los emails silenciados (por reportes de " -"spam o alto indice de rebote)." +"spam o alto índice de rebote)." #: src/app/main/errors.cljs:483 msgid "errors.migration-in-progress" @@ -1831,7 +1831,7 @@ msgstr "El perfil esta blockeado" #: src/app/main/ui/auth/recovery_request.cljs:53, src/app/main/ui/dashboard/team.cljs:225, src/app/main/ui/dashboard/team.cljs:731, src/app/main/ui/dashboard/team.cljs:1108, src/app/main/ui/onboarding/team_choice.cljs:109, src/app/main/ui/settings/change_email.cljs:33 msgid "errors.profile-is-muted" msgstr "" -"Tu perfil tiene los emails silenciados (por reportes de spam o alto indice " +"Tu perfil tiene los emails silenciados (por reportes de spam o alto índice " "de rebote)." #: src/app/main/data/auth.cljs:346, src/app/main/ui/auth/register.cljs:91 @@ -2609,7 +2609,7 @@ msgstr "Crear nuevo token de acceso" #: src/app/main/ui/settings/integrations.cljs:632 msgid "integrations.access-tokens.empty.add-one" -msgstr "Pulsa el botón \"Crear nuevo token de accesso\" para generar uno." +msgstr "Pulsa el botón \"Crear nuevo token de acceso\" para generar uno." #: src/app/main/ui/settings/integrations.cljs:631 msgid "integrations.access-tokens.empty.no-access-tokens" @@ -2622,7 +2622,7 @@ msgstr "Tokens de acceso personales" #: src/app/main/ui/settings/integrations.cljs:611 msgid "integrations.access-tokens.personal.description" msgstr "" -"Los tokens de accesso personales funcionan como una alternativa a nuestro " +"Los tokens de acceso personales funcionan como una alternativa a nuestro " "sistema de autenticación usuario/password y se pueden usar para permitir a " "otras aplicaciones acceso a la API interna de Penpot" @@ -2632,7 +2632,7 @@ msgstr "Copiar al portapapeles" #: src/app/main/ui/settings/integrations.cljs:253 msgid "integrations.create-access-token.title" -msgstr "Crear token de accesso" +msgstr "Crear token de acceso" #: src/app/main/ui/settings/integrations.cljs:252 msgid "integrations.create-access-token.title.created" @@ -8426,19 +8426,19 @@ msgstr "Opciones avanzadas" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:695, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:702 msgid "workspace.options.layout-item.layout-item-max-h" -msgstr "Altura.Max" +msgstr "Altura máx." #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:633, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:640 msgid "workspace.options.layout-item.layout-item-max-w" -msgstr "Ancho.Max" +msgstr "Ancho máx." #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:664, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:672 msgid "workspace.options.layout-item.layout-item-min-h" -msgstr "Altura.Min" +msgstr "Altura mín." #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:600, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:609 msgid "workspace.options.layout-item.layout-item-min-w" -msgstr "Ancho.Min" +msgstr "Ancho mín." #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused @@ -9706,7 +9706,7 @@ msgstr "Nombre del grupo" #: src/app/main/ui/workspace/tokens/sets.cljs #, unused msgid "workspace.tokens.grouping-set-alert" -msgstr "La agrupación de sets aun no está soportada." +msgstr "La agrupación de sets aún no está soportada." #: src/app/main/ui/workspace/tokens/import/modal.cljs:232 msgid "workspace.tokens.import-button-prefix"