🌐 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.<locale>.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
This commit is contained in:
Andrey Antukh 2026-09-22 10:49:34 +00:00
parent a9ec397368
commit cffce049e3
5 changed files with 1103 additions and 230 deletions

View File

@ -58,25 +58,22 @@ high-coverage support reference, never the base.
## QA before commit
- Run `node ./scripts/check-translations.js -l <locale>` 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.<locale>.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/<locale>.po`.

View File

@ -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 <locale>] [--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.<locale>.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 <locale>] [--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 <locale>] [--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);
}

View File

@ -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]

View File

@ -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
é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]

View File

@ -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"