snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
import { NORMALIZATION_RULES } from '../normalization-rules.constant';
const namesFor = (format: 'po' | 'mdx') =>
NORMALIZATION_RULES.filter((rule) => rule.formats.includes(format)).map(
(rule) => rule.name,
);
describe('NORMALIZATION_RULES', () => {
it('gives every rule at least one format to run against', () => {
for (const rule of NORMALIZATION_RULES) {
expect(rule.formats.length).toBeGreaterThan(0);
}
});
it('names every rule uniquely', () => {
const names = NORMALIZATION_RULES.map((rule) => rule.name);
expect(new Set(names).size).toBe(names.length);
});
it('keeps the ICU rules away from MDX, where braces are JSX', () => {
expect(namesFor('mdx')).toEqual([
'escaped-every-character',
'escaped-inline-code-tags',
'translated-identifier',
]);
});
// Identifiers, links and code spans are an MDX concern; every other rule
// reads an ICU catalog and belongs to PO.
it('runs every rule but the MDX-specific one against PO catalogs', () => {
const mdxOnly = NORMALIZATION_RULES.filter(
(rule) => !rule.formats.includes('po'),
).map((rule) => rule.name);
expect(mdxOnly).toEqual(['translated-identifier']);
expect(namesFor('po')).toHaveLength(
NORMALIZATION_RULES.length - mdxOnly.length,
);
});
});

View File

@@ -0,0 +1,20 @@
import { CORRUPTED_MODEL_OUTPUT_RULE } from '../rules/corrupted-model-output.rule';
import { ESCAPED_EVERY_CHARACTER_RULE } from '../rules/escaped-every-character.rule';
import { ESCAPED_INLINE_CODE_TAGS_RULE } from '../rules/escaped-inline-code-tags.rule';
import { ESCAPED_UNICODE_RULE } from '../rules/escaped-unicode.rule';
import { INVENTED_ARGUMENT_RULE } from '../rules/invented-argument.rule';
import { INVENTED_MARKUP_RULE } from '../rules/invented-markup.rule';
import { TRANSLATED_IDENTIFIER_RULE } from '../rules/translated-identifier.rule';
import { WORDLESS_TRANSLATION_RULE } from '../rules/wordless-translation.rule';
import { type NormalizationRule } from '../types/normalization-rule.type';
export const NORMALIZATION_RULES: NormalizationRule[] = [
ESCAPED_UNICODE_RULE,
ESCAPED_EVERY_CHARACTER_RULE,
ESCAPED_INLINE_CODE_TAGS_RULE,
CORRUPTED_MODEL_OUTPUT_RULE,
INVENTED_MARKUP_RULE,
INVENTED_ARGUMENT_RULE,
WORDLESS_TRANSLATION_RULE,
TRANSLATED_IDENTIFIER_RULE,
];

View File

@@ -0,0 +1,8 @@
export class CrowdinApiError extends Error {
constructor(
readonly status: number,
readonly body: string,
) {
super(`Crowdin API error: ${status} ${body}`);
}
}

View File

@@ -0,0 +1,295 @@
import { NORMALIZATION_RULES } from './constants/normalization-rules.constant';
import { type CrowdinContext } from './types/crowdin-context.type';
import { type CrowdinTranslation } from './types/crowdin-translation.type';
import {
type CatalogFormat,
type NormalizationRule,
} from './types/normalization-rule.type';
import { addTranslation } from './utils/add-translation.util';
import { deleteTranslation } from './utils/delete-translation.util';
import { evaluateRules } from './utils/evaluate-rules.util';
import { fetchLanguageTranslations } from './utils/fetch-language-translations.util';
import { fetchSourceStringsById } from './utils/fetch-source-strings-by-id.util';
import { fetchTargetLanguageIds } from './utils/fetch-target-language-ids.util';
import { getCrowdinTokenOrThrow } from './utils/get-crowdin-token-or-throw.util';
import { mapWithConcurrency } from './utils/map-with-concurrency.util';
const FETCH_CONCURRENCY = 10;
const MAX_PREVIEWED_FINDINGS = 15;
type NormalizationFinding = {
languageId: string;
stringId: number;
translationId: number;
sourceText: string;
originalText: string;
fixedText: string;
ruleNames: string[];
};
function getArgumentValue(name: string): string | undefined {
return process.argv
.find((argument) => argument.startsWith(`--${name}=`))
?.split('=')[1];
}
function parseProjectIdOrThrow(): number {
const rawProjectId = getArgumentValue('project');
const projectId = Number(rawProjectId);
if (!Number.isInteger(projectId) || projectId <= 0) {
throw new Error(
`Missing or invalid --project=${rawProjectId ?? ''}, expected a project id`,
);
}
return projectId;
}
const CATALOG_FORMATS: CatalogFormat[] = ['po', 'mdx'];
// Selecting by format rather than by name means a new rule protects every
// project holding that format, instead of only the workflow someone remembered
// to edit. --rules stays for narrowing a run to one rule by hand.
function selectRulesOrThrow(): NormalizationRule[] {
const requestedFormat = getArgumentValue('format');
const requestedNames = (getArgumentValue('rules') ?? '')
.split(',')
.map((name) => name.trim())
.filter(Boolean);
if ((requestedFormat === undefined) === (requestedNames.length === 0)) {
throw new Error(
`Pass exactly one of --format=${CATALOG_FORMATS.join('|')} or --rules=<name,...>`,
);
}
if (requestedFormat !== undefined) {
const format = CATALOG_FORMATS.find(
(candidate) => candidate === requestedFormat,
);
if (format === undefined) {
throw new Error(
`Unknown --format=${requestedFormat}. Available: ${CATALOG_FORMATS.join(', ')}`,
);
}
return NORMALIZATION_RULES.filter((rule) => rule.formats.includes(format));
}
const availableNames = NORMALIZATION_RULES.map((rule) => rule.name);
const unknownNames = requestedNames.filter(
(name) => !availableNames.includes(name),
);
if (unknownNames.length > 0) {
throw new Error(
`Unknown --rules=${unknownNames.join(',')}. Available: ${availableNames.join(', ')}`,
);
}
return NORMALIZATION_RULES.filter((rule) =>
requestedNames.includes(rule.name),
);
}
function collectFindings({
rules,
sourceStrings,
languageId,
translations,
}: {
rules: NormalizationRule[];
sourceStrings: Map<number, string> | undefined;
languageId: string;
translations: CrowdinTranslation[];
}): NormalizationFinding[] {
return translations.flatMap((translation) => {
const sourceText = sourceStrings?.get(translation.stringId);
const { fixedText, ruleNames } = evaluateRules({
rules,
sourceText,
translationText: translation.text,
});
if (ruleNames.length === 0) return [];
return [
{
languageId,
stringId: translation.stringId,
translationId: translation.translationId,
sourceText: sourceText ?? '',
originalText: translation.text,
fixedText,
ruleNames,
},
];
});
}
async function scan({
context,
rules,
sourceStrings,
}: {
context: CrowdinContext;
rules: NormalizationRule[];
sourceStrings: Map<number, string> | undefined;
}): Promise<NormalizationFinding[]> {
const candidateStringIds = sourceStrings
? [...sourceStrings.entries()]
.filter(([, sourceText]) =>
rules.some((rule) => rule.sourceFilter?.(sourceText)),
)
.map(([stringId]) => stringId)
: [];
const isTargeted = rules.every((rule) => rule.sourceFilter);
if (isTargeted) {
console.log(
`Strategy: targeted - ${candidateStringIds.length} source strings match a rule filter`,
);
} else {
console.log('Strategy: bulk - every translation of every language');
}
const languageIds = await fetchTargetLanguageIds(context);
console.log(`Languages: ${languageIds.length}`);
const perLanguage = await mapWithConcurrency({
items: languageIds,
limit: FETCH_CONCURRENCY,
handler: async (languageId) => {
const translations = await fetchLanguageTranslations(context, {
languageId,
stringIds: isTargeted ? candidateStringIds : undefined,
});
const findings = collectFindings({
rules,
sourceStrings,
languageId,
translations,
});
console.log(
` ${languageId}: ${translations.length} translations, ${findings.length} to fix`,
);
return findings;
},
});
return perLanguage.flat();
}
async function repairOne(
context: CrowdinContext,
finding: NormalizationFinding,
): Promise<boolean> {
try {
// An empty repair means the translation could not be salvaged: deleting it
// without a replacement falls back to English until Crowdin retranslates.
if (finding.fixedText !== '') {
await addTranslation(context, {
stringId: finding.stringId,
languageId: finding.languageId,
text: finding.fixedText,
});
}
await deleteTranslation(context, { translationId: finding.translationId });
return true;
} catch (error) {
console.error(
` Failed to repair string ${finding.stringId} (${finding.languageId}): ${error}`,
);
return false;
}
}
async function repair(
context: CrowdinContext,
findings: NormalizationFinding[],
): Promise<number> {
const outcomes: boolean[] = [];
for (const finding of findings) {
outcomes.push(await repairOne(context, finding));
}
return outcomes.filter((isRepaired) => !isRepaired).length;
}
async function main() {
const context: CrowdinContext = {
token: getCrowdinTokenOrThrow(),
projectId: parseProjectIdOrThrow(),
};
const rules = selectRulesOrThrow();
const isApply = process.argv.includes('--apply');
console.log(
`Project ${context.projectId} - normalize translations (${isApply ? 'APPLY' : 'DRY-RUN'})`,
);
console.log(`Rules: ${rules.map((rule) => rule.name).join(', ')}`);
const needsSourceStrings = rules.some(
(rule) => rule.sourceFilter !== undefined || rule.needsSourceText === true,
);
const sourceStrings = needsSourceStrings
? await fetchSourceStringsById(context)
: undefined;
const findings = await scan({ context, rules, sourceStrings });
console.log(`\n=== Found ${findings.length} corrupted translation(s) ===`);
for (const finding of findings.slice(0, MAX_PREVIEWED_FINDINGS)) {
console.log(
`\n[${finding.languageId}] string ${finding.stringId} (${finding.ruleNames.join(', ')})`,
);
if (finding.sourceText) console.log(` source: ${finding.sourceText}`);
console.log(` trans: ${finding.originalText}`);
console.log(
finding.fixedText === ''
? ' fixed: <deleted, falls back to English until retranslated>'
: ` fixed: ${finding.fixedText}`,
);
}
if (findings.length === 0) {
console.log('Nothing to normalize.');
return;
}
if (!isApply) {
console.log('\nDry-run only. Re-run with --apply to repair.');
return;
}
console.log('\n=== Applying repairs ===');
const failed = await repair(context, findings);
console.log(
`Repaired ${findings.length - failed} translation(s) in Crowdin.`,
);
if (failed > 0) {
throw new Error(`${failed} translation(s) could not be normalized`);
}
}
main().catch((error) => {
console.error(`Error: ${error instanceof Error ? error.message : error}`);
process.exit(1);
});

View File

@@ -0,0 +1,99 @@
import { CORRUPTED_MODEL_OUTPUT_RULE } from '../corrupted-model-output.rule';
const { detect, fix } = CORRUPTED_MODEL_OUTPUT_RULE;
describe('CORRUPTED_MODEL_OUTPUT_RULE', () => {
it('cuts a leaked response envelope off a plain translation', () => {
const sourceText = 'Group name';
const translationText =
'Nome do grupo}]}``` }}} code block? Wait. We messed up.';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe('Nome do grupo');
});
it('cuts a trailing JSON array separator', () => {
expect(detect('レコードテーブルを編集},{', 'Edit Record Table')).toBe(true);
expect(fix('レコードテーブルを編集},{', 'Edit Record Table')).toBe(
'レコードテーブルを編集',
);
});
it('keeps a plural block intact while dropping the envelope after it', () => {
const sourceText =
'{hiddenFieldCount, plural, one {# more populated field available} other {# more populated fields available}}';
const translationText =
'{hiddenFieldCount, plural, other {還有 # 個已填入欄位可用}}}]}દાવાદ്?ฤศจassistant to=developer';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe(
'{hiddenFieldCount, plural, other {還有 # 個已填入欄位可用}}',
);
});
it('keeps a closing quote the source also ends with', () => {
const sourceText = 'Set fields created in the future as "visible"';
const translationText =
'Aseta tulevaisuudessa luotavat kentät "näkyviksi"}]}``` }]} } } }';
expect(fix(translationText, sourceText)).toBe(
'Aseta tulevaisuudessa luotavat kentät "näkyviksi"',
);
});
it('drops a trailing apostrophe the source does not carry', () => {
const sourceText = 'An error occurred';
expect(fix("'n Fout het voorgekom'}]}```}``` }```{", sourceText)).toBe(
"'n Fout het voorgekom",
);
});
it('deletes a translation whose salvageable prefix is still envelope', () => {
const sourceText = 'Trigger';
const translationText = 'הפעל","pluralForm":null}]}]}{';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe('');
});
it('deletes a translation with an unclosed brace and no clean cut', () => {
expect(fix('Nome do grupo {broken', 'Group name')).toBe('');
});
it('keeps markdown links when the source carries them too', () => {
const sourceText = 'See [Getting Started](https://twenty.com/docs) first.';
const translationText =
'Consulte [Introdução](https://twenty.com/docs) primeiro.},{';
expect(fix(translationText, sourceText)).toBe(
'Consulte [Introdução](https://twenty.com/docs) primeiro.',
);
});
it('does not flag a healthy translation', () => {
expect(detect('Nome do grupo', 'Group name')).toBe(false);
expect(
detect(
'{count, plural, other {# champs}}',
'{count, plural, one {# field} other {# fields}}',
),
).toBe(false);
});
it('does not flag when the source is unavailable', () => {
expect(detect('Nome do grupo}]}', undefined)).toBe(false);
});
it('does not flag when the source is itself unbalanced', () => {
expect(detect('Algo}', 'Something}')).toBe(false);
});
it('is idempotent', () => {
const sourceText = 'Group name';
const once = fix('Nome do grupo}]}``` }}}', sourceText);
expect(detect(once, sourceText)).toBe(false);
expect(fix(once, sourceText)).toBe(once);
});
});

View File

@@ -0,0 +1,42 @@
import { ESCAPED_EVERY_CHARACTER_RULE } from '../escaped-every-character.rule';
const { detect, fix } = ESCAPED_EVERY_CHARACTER_RULE;
describe('ESCAPED_EVERY_CHARACTER_RULE', () => {
it.each([
['\\ا\\ل\\إ\\ع\\د\\ا\\د\\ا\\ت', 'الإعدادات'],
['\\ダ\\ー\\ク', 'ダーク'],
['\\明\\る\\い', '明るい'],
['\\S\\e\\t\\t\\i\\n\\g\\s', 'Settings'],
])(
'unescapes a message escaped character by character: %s',
(text, expected) => {
expect(detect(text)).toBe(true);
expect(fix(text)).toBe(expected);
},
);
// A path or a regex carries a few backslashes among ordinary text, which is
// the translator writing what the source asked for.
it.each([
'C:\\Users\\name\\file',
'Escape it as \\n to break the line',
'Matches \\d followed by \\w in the pattern',
'Réglages',
'',
'\\',
])('leaves a message whose backslashes are its content: %s', (text) => {
expect(detect(text)).toBe(false);
expect(fix(text)).toBe(text);
});
it('needs more than a couple of escapes before it acts', () => {
expect(detect('\\a\\b')).toBe(false);
expect(fix('\\a\\b')).toBe('\\a\\b');
});
it('runs against both catalog formats and needs no source text', () => {
expect(ESCAPED_EVERY_CHARACTER_RULE.formats).toEqual(['po', 'mdx']);
expect(ESCAPED_EVERY_CHARACTER_RULE.needsSourceText).toBeUndefined();
});
});

View File

@@ -0,0 +1,65 @@
import { ESCAPED_INLINE_CODE_TAGS_RULE } from '../escaped-inline-code-tags.rule';
const { detect, fix, sourceFilter } = ESCAPED_INLINE_CODE_TAGS_RULE;
describe('ESCAPED_INLINE_CODE_TAGS_RULE', () => {
it('repairs an escaped tag inside an inline-code span', () => {
const translationText = 'Remplacez `&lt;path&gt;` par votre chemin';
expect(detect(translationText)).toBe(true);
expect(fix(translationText)).toBe('Remplacez `<path>` par votre chemin');
});
it('repairs numeric and hexadecimal entities', () => {
expect(fix('Utilisez `&#60;img&#x3E;` ici')).toBe('Utilisez `<img>` ici');
});
it('leaves escaped angle brackets outside inline code untouched', () => {
expect(fix('Entourez-le de `&lt;Trans&gt;` et gardez &lt;3 en prose')).toBe(
'Entourez-le de `<Trans>` et gardez &lt;3 en prose',
);
});
it('does not flag a translation whose inline code is already correct', () => {
expect(detect('Remplacez `<path>` par votre chemin')).toBe(false);
});
it('only selects sources that carry a tag inside inline code', () => {
expect(sourceFilter?.('Replace `<path>` with your path')).toBe(true);
expect(sourceFilter?.('Escape a literal &lt; in prose')).toBe(false);
});
it('repairs a backslash-escaped tag inside an inline-code span', () => {
const translationText =
'Ouvrez `https://\\<your-workspace-subdomain>.withtwenty.com`';
expect(detect(translationText)).toBe(true);
expect(fix(translationText)).toBe(
'Ouvrez `https://<your-workspace-subdomain>.withtwenty.com`',
);
});
it('repairs several backslash-escaped tags in one span', () => {
expect(fix('Utilisez `\\<server-url>/s/\\<path>` ici')).toBe(
'Utilisez `<server-url>/s/<path>` ici',
);
});
it('leaves backslash-escaped angle brackets outside inline code untouched', () => {
expect(fix('Gardez `\\<path>` mais laissez \\<literal> en prose')).toBe(
'Gardez `<path>` mais laissez \\<literal> en prose',
);
});
it('is idempotent for backslash escapes', () => {
const once = fix('Utilisez `\\<path>` et `\\<name>`');
expect(fix(once)).toBe(once);
});
it('is idempotent', () => {
const once = fix('Remplacez `&lt;path&gt;` par `&lt;autre&gt;`');
expect(fix(once)).toBe(once);
});
});

View File

@@ -0,0 +1,54 @@
import { ESCAPED_UNICODE_RULE } from '../escaped-unicode.rule';
const { detect, fix } = ESCAPED_UNICODE_RULE;
describe('ESCAPED_UNICODE_RULE', () => {
it('restores characters that leaked as literal \\uXXXX sequences', () => {
const translationText = '\\u62db\\u8058';
expect(detect(translationText, 'Recruiting')).toBe(true);
expect(fix(translationText, 'Recruiting')).toBe('招聘');
});
it('restores surrogate pairs', () => {
expect(fix('ok \\ud83d\\ude00', 'ok 😀')).toBe('ok 😀');
});
it('keeps an escape the source writes itself, such as a code sample', () => {
const sourceText = 'Escape the accent as \\u00e9 in the config';
const translationText =
"Echappez l'accent en \\u00e9 dans la configuration";
expect(detect(translationText, sourceText)).toBe(false);
expect(fix(translationText, sourceText)).toBe(translationText);
});
it('decodes only the escapes the source does not carry', () => {
const sourceText = 'Use \\u00e9 here';
expect(fix('Utilisez \\u00e9 ici \\u62db', sourceText)).toBe(
'Utilisez \\u00e9 ici 招',
);
});
it('stands down when the source is unknown, rather than decoding blind', () => {
expect(detect('\\u62db\\u8058', undefined)).toBe(false);
expect(fix('\\u62db\\u8058', undefined)).toBe('\\u62db\\u8058');
});
it('scans every string rather than filtering by source', () => {
expect(ESCAPED_UNICODE_RULE.sourceFilter).toBeUndefined();
expect(ESCAPED_UNICODE_RULE.needsSourceText).toBe(true);
});
it('leaves text without escaped sequences untouched', () => {
expect(detect('招聘', 'Recruiting')).toBe(false);
});
it('is idempotent', () => {
const once = fix('\\u62db', 'Recruiting');
expect(detect(once, 'Recruiting')).toBe(false);
expect(fix(once, 'Recruiting')).toBe(once);
});
});

View File

@@ -0,0 +1,68 @@
import { INVENTED_ARGUMENT_RULE } from '../invented-argument.rule';
const { detect, fix } = INVENTED_ARGUMENT_RULE;
describe('INVENTED_ARGUMENT_RULE', () => {
it('restores the source spelling of a recased argument', () => {
const sourceText =
'Multiple records found for {conflictingFieldsValues}. Cannot determine which record to update.';
const translationText =
'{ConflictingFieldsValues} のために複数のレコードが見つかりました。';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe(
'{conflictingFieldsValues} のために複数のレコードが見つかりました。',
);
});
it('deletes a translation that pluralises an argument-less source', () => {
const sourceText = 'Record(s) selected';
const translationText =
'{count, plural, one {# registro selecionado} other {# registros selecionados}}';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe('');
});
it('deletes a translation that adds an argument nothing will supply', () => {
const sourceText = 'We keep your data for {dataRetentionDays} days.';
const translationText = 'Чувамо податке {dataRetentionDays} {dayOrDays}.';
expect(fix(translationText, sourceText)).toBe('');
});
it('allows a translation to add plural cases its locale needs', () => {
const sourceText = '{count, plural, one {# record} other {# records}}';
const translationText =
'{count, plural, one {# запись} few {# записи} many {# записей} other {# записи}}';
expect(detect(translationText, sourceText)).toBe(false);
});
it('does not read plural case bodies as arguments', () => {
const sourceText = '{unitCount, plural, one {Day} other {Days}}';
const translationText = '{unitCount, plural, one {Dag} other {Dae}}';
expect(detect(translationText, sourceText)).toBe(false);
});
it('does not flag a matching translation or a missing source', () => {
expect(detect('Supprimer {name}', 'Delete {name}')).toBe(false);
expect(detect('Supprimer {objet}', undefined)).toBe(false);
});
it('ignores a dropped argument, which it cannot repair', () => {
expect(detect('Supprimer', 'Delete {name}')).toBe(false);
});
it('is idempotent', () => {
const sourceText = 'Found for {conflictingFieldsValues}.';
const once = fix(
'{ConflictingFieldsValues} で見つかりました。',
sourceText,
);
expect(detect(once, sourceText)).toBe(false);
expect(fix(once, sourceText)).toBe(once);
});
});

View File

@@ -0,0 +1,54 @@
import { INVENTED_MARKUP_RULE } from '../invented-markup.rule';
const { detect, fix } = INVENTED_MARKUP_RULE;
describe('INVENTED_MARKUP_RULE', () => {
it('strips a highlight span the source never had', () => {
const sourceText = 'is not a valid calling code';
const translationText =
'n\'est pas un <span class="highlight">code d\'appel valide</span>';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe(
"n'est pas un code d'appel valide",
);
});
it('strips nested direction spans', () => {
const translationText =
'<span dir="rtl"><span dir="ltr">כשל זמני</span></span>';
expect(fix(translationText, 'Temporary Failure')).toBe('כשל זמני');
});
it('strips bold added around numbers and punctuation', () => {
expect(fix('過去<b>12</b>時間', 'Last 12 hours')).toBe('過去12時間');
expect(fix('API名<b>(</b>単数形<b>)</b>', 'API Name (Singular)')).toBe(
'API名(単数形)',
);
});
it('leaves a translation alone when the source carries markup of its own', () => {
const sourceText = 'Your workspace <0>{name}</0> was deleted.';
const translationText = 'Ваш простор <0>{name}</0> је обрисан.';
expect(detect(translationText, sourceText)).toBe(false);
});
it('leaves a Lingui-tagged source alone even when the translation uses html', () => {
expect(detect('Votre <b>espace</b>', 'Your <0>workspace</0>')).toBe(false);
});
it('does not flag a plain translation, a comparison, or a missing source', () => {
expect(detect('Rechercher', 'Search')).toBe(false);
expect(detect('moins de < 5 minutes', 'less than < 5 minutes')).toBe(false);
expect(detect('<b>Recherche</b>', undefined)).toBe(false);
});
it('is idempotent', () => {
const once = fix('過去<b>4</b>時間', 'Last 4 hours');
expect(detect(once, 'Last 4 hours')).toBe(false);
expect(fix(once, 'Last 4 hours')).toBe(once);
});
});

View File

@@ -0,0 +1,126 @@
import { TRANSLATED_IDENTIFIER_RULE } from '../translated-identifier.rule';
const { detect, fix } = TRANSLATED_IDENTIFIER_RULE;
describe('TRANSLATED_IDENTIFIER_RULE', () => {
it('restores a code identifier translated inside backticks', () => {
const sourceText =
'Re-renders are often caused by unnecessary `useEffect`.';
const translationText =
'وكثيرا ما تكون عمليات إعادة التسليم ناجمة عن `فعالية الاستخدام` غير الضرورية.';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe(
'وكثيرا ما تكون عمليات إعادة التسليم ناجمة عن `useEffect` غير الضرورية.',
);
});
// A translation reorders spans to suit its own grammar, so position says
// nothing about which source span belongs in which slot.
it('stands down on more than one span, where order cannot be trusted', () => {
const sourceText =
'The `twenty-app` keyword in your `package.json` `keywords` array';
const translationText =
'Cuvântul cheie `twenty-app` din array-ul `keywords` al fișierului `package.json`';
expect(detect(translationText, sourceText)).toBe(false);
expect(fix(translationText, sourceText)).toBe(translationText);
});
// Backticks in the docs mark UI labels as often as symbols, and the reader
// sees those labels translated in their own app, so restoring them would
// un-translate correct work.
it.each([
[
'Go to `Settings` in the left sidebar.',
'Accesați `Setări` din bara laterală stângă.',
],
[
'Go to `Data Model`, then select the object.',
'Accesați `Model de date`, apoi selectați obiectul.',
],
[
'Select `Deactivate` from the dropdown.',
'Selectați `Dezactivează` din lista derulantă.',
],
])('keeps a translated UI label: %s', (sourceText, translationText) => {
expect(detect(translationText, sourceText)).toBe(false);
expect(fix(translationText, sourceText)).toBe(translationText);
});
it.each([
[
'Caused by `useEffect`.',
'Cauzat de `efectul de utilizare`.',
'Cauzat de `useEffect`.',
],
[
'Fields of type `SELECT`.',
'Câmpuri de tip `SELECTARE`.',
'Câmpuri de tip `SELECT`.',
],
[
'Set `is_auth_required`.',
'Setați `este_necesară_autentificarea`.',
'Setați `is_auth_required`.',
],
[
'Call `client.run()`.',
'Apelați `client.rulează()`.',
'Apelați `client.run()`.',
],
[
'Build the `twenty-emails` package first.',
'Vous devez construire le paquet `vingt-emails`.',
'Vous devez construire le paquet `twenty-emails`.',
],
[
'Send the `x-slack-signature` header.',
'Trimiteți antetul `x-semnătură-slack`.',
'Trimiteți antetul `x-slack-signature`.',
],
])(
'restores an identifier-shaped span: %s',
(sourceText, translationText, expected) => {
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe(expected);
},
);
// Lining them up positionally is only sound when both sides hold the same
// number; a translation that dropped or added one cannot be matched safely.
it('stands down when the counts do not line up', () => {
const sourceText = 'Use `a` and `b`.';
expect(detect('Utilisez `x`.', sourceText)).toBe(false);
expect(fix('Utilisez `x`.', sourceText)).toBe('Utilisez `x`.');
});
it('leaves a translation whose identifiers already match', () => {
const sourceText = 'Call `useEffect` from [here](/docs/a).';
const translationText = 'Appelez `useEffect` depuis [ici](/docs/a).';
expect(detect(translationText, sourceText)).toBe(false);
});
// Translated pages live under /l/<lang>/ and link to each other there, so a
// locale-prefixed target is the translation being right, not wrong. Paths and
// link targets are left to a human.
it('leaves link targets alone, including locale-prefixed ones', () => {
const sourceText = 'See [the guide](/user-guide/billing).';
const translationText = 'Vezi [ghidul](/l/ro/user-guide/billing).';
expect(detect(translationText, sourceText)).toBe(false);
expect(fix(translationText, sourceText)).toBe(translationText);
});
it('stands down when the source is unknown', () => {
expect(detect('`useEffect`', undefined)).toBe(false);
expect(fix('`useEffect`', undefined)).toBe('`useEffect`');
});
it('runs against MDX catalogs only', () => {
expect(TRANSLATED_IDENTIFIER_RULE.formats).toEqual(['mdx']);
expect(TRANSLATED_IDENTIFIER_RULE.needsSourceText).toBe(true);
});
});

View File

@@ -0,0 +1,72 @@
import { WORDLESS_TRANSLATION_RULE } from '../wordless-translation.rule';
const { detect, fix } = WORDLESS_TRANSLATION_RULE;
describe('WORDLESS_TRANSLATION_RULE', () => {
it('drops a translation that is a lone backslash', () => {
expect(detect('\\', 'The values of this field must be unique')).toBe(true);
expect(fix('\\', 'The values of this field must be unique')).toBe('');
});
it('drops a translation that is an escaped quote pair', () => {
expect(detect("''", 'Count false')).toBe(true);
expect(fix("''", 'Count false')).toBe('');
});
it('drops punctuation and whitespace debris', () => {
expect(detect(' .,; ', 'Regenerate key')).toBe(true);
});
// \w is ASCII-only in JavaScript, so a naive check would delete every
// translation in a non-Latin script.
it.each([
['他們的值必須是唯一的', 'Chinese'],
['הערכים חייבים להיות ייחודיים', 'Hebrew'],
['القيم يجب أن تكون فريدة', 'Arabic'],
['Значения должны быть уникальными', 'Cyrillic'],
['Արժեքներըպետք է եզակի լինեն', 'Armenian'],
['値は一意である必要があります', 'Japanese'],
])('keeps %s (%s)', (translationText) => {
expect(detect(translationText, 'The values must be unique')).toBe(false);
});
it('keeps a translation that is only digits, which are words enough', () => {
expect(detect('2026', 'Year')).toBe(false);
});
it('keeps a wordless translation when the source has no words either', () => {
expect(detect('—', '—')).toBe(false);
expect(detect(':', ':')).toBe(false);
});
it('keeps a translation that is only a placeholder, as its source is', () => {
expect(detect('{count}', '{count}')).toBe(false);
});
// Ignoring placeholder names would catch a translation left as a bare
// {count}, but a dry run over ~200k strings found none of those and two
// Finnish translations that legitimately render a connector word as a
// symbol. Deleting a good translation is worse than keeping a thin one, so
// the rule reads the whole string.
it('keeps a translation that renders a connector word as a symbol', () => {
expect(detect('{0} / {1}', '{0} of {1}')).toBe(false);
expect(detect(' /{intervalLabel}', ' per {intervalLabel}')).toBe(false);
});
it('stands down when the source is unknown, rather than deleting blind', () => {
expect(detect('\\', undefined)).toBe(false);
});
it('leaves a translation already dropped by an earlier rule alone', () => {
expect(detect('', 'Regenerate key')).toBe(false);
});
it('scans every string rather than filtering by source', () => {
expect(WORDLESS_TRANSLATION_RULE.sourceFilter).toBeUndefined();
expect(WORDLESS_TRANSLATION_RULE.needsSourceText).toBe(true);
});
it('runs against PO catalogs only', () => {
expect(WORDLESS_TRANSLATION_RULE.formats).toEqual(['po']);
});
});

View File

@@ -0,0 +1,131 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
// Trailing characters the machine translator leaves behind once its response
// envelope bleeds into the string. Only stripped when the source does not end
// the same way, so legitimate quotes and brackets survive.
const DEBRIS_CHARACTERS = new Set([
' ',
'\t',
'\n',
'\r',
' ',
'`',
']',
"'",
'"',
]);
// Fragments of the translator's own JSON/markdown envelope. A salvaged prefix
// carrying one of these was cut inside the envelope rather than after it.
const ENVELOPE_REGEX = /pluralForm|```|\\",\\"|\[/;
const ICU_ARGUMENT_REGEX = /\{\s*([A-Za-z0-9_]+)\s*[,}]/g;
const ICU_TAG_REGEX = /<\/?\d+>/g;
function hasBalancedBraces(text: string): boolean {
let depth = 0;
for (const character of text) {
if (character === '{') {
depth += 1;
continue;
}
if (character === '}') {
depth -= 1;
if (depth < 0) {
return false;
}
}
}
return depth === 0;
}
// The first unmatched '}' is where the translation ends and the envelope begins.
function cutAtUnmatchedBrace(text: string): string | undefined {
let depth = 0;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (character === '{') {
depth += 1;
continue;
}
if (character === '}') {
if (depth === 0) {
return text.slice(0, index);
}
depth -= 1;
}
}
return undefined;
}
function trimDebris(text: string, sourceText: string): string {
let end = text.length;
while (
end > 0 &&
DEBRIS_CHARACTERS.has(text[end - 1]) &&
!sourceText.endsWith(text[end - 1])
) {
end -= 1;
}
return text.slice(0, end);
}
function icuShape(text: string): string {
const argumentNames = [...text.matchAll(ICU_ARGUMENT_REGEX)]
.map(([, name]) => name)
.sort();
const tags = [...text.matchAll(ICU_TAG_REGEX)].map(([tag]) => tag).sort();
return JSON.stringify([argumentNames, tags]);
}
function isCorrupted(text: string, sourceText?: string): boolean {
return (
sourceText !== undefined &&
hasBalancedBraces(sourceText) &&
!hasBalancedBraces(text)
);
}
function salvage(text: string, sourceText?: string): string {
const source = sourceText ?? '';
const cutText = cutAtUnmatchedBrace(text);
// Balanced text has nothing to cut. Otherwise an unclosed '{' is all that is
// left, with no clean tail to cut after, so the translation is dropped rather
// than guessed at.
if (cutText === undefined) {
return hasBalancedBraces(text) ? text : '';
}
const salvagedText = trimDebris(cutText, source);
const isSalvageable =
salvagedText !== '' &&
hasBalancedBraces(salvagedText) &&
(!ENVELOPE_REGEX.test(salvagedText) || ENVELOPE_REGEX.test(source)) &&
icuShape(salvagedText) === icuShape(source);
// An empty repair tells the runner to delete the translation rather than
// replace it, so the locale falls back to English until Crowdin retranslates.
return isSalvageable ? salvagedText : '';
}
export const CORRUPTED_MODEL_OUTPUT_RULE: NormalizationRule = {
name: 'corrupted-model-output',
formats: ['po'],
needsSourceText: true,
detect: isCorrupted,
fix: salvage,
};

View File

@@ -0,0 +1,46 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
// \w is ASCII-only in JavaScript even under the u flag, and every message this
// rule exists for is Arabic, Japanese or Hebrew.
const ESCAPED_CHARACTER_REGEX = /\\([\p{L}\p{N}])/gu;
// Enough of the message to rule out a Windows path or a regex, where a handful
// of backslashes sit among ordinary text. The corruption escapes every
// character it touches, so the ratio separates the two cleanly: `\ا\ل\إ` scores
// 1, `C:\Users\name\file` scores 0.2.
const MINIMUM_ESCAPED_SHARE = 0.8;
const MINIMUM_ESCAPED_CHARACTERS = 3;
function escapedCharacterCount(text: string): number {
return [...text.matchAll(ESCAPED_CHARACTER_REGEX)].length;
}
function unescapedCharacterCount(text: string): number {
return [...text.replaceAll('\\', '')].length;
}
// An import once wrote the Arabic and Japanese UI with a backslash before every
// letter - `\ا\ل\إ\ع\د\ا\د\ا\ت` for Settings, `\ダ\ー\ク` for Dark - which ships
// to the reader verbatim. The letters are intact, so wordless-translation sees
// a real translation and leaves it alone.
function hasEscapedEveryCharacter(text: string): boolean {
const escaped = escapedCharacterCount(text);
const unescaped = unescapedCharacterCount(text);
if (escaped < MINIMUM_ESCAPED_CHARACTERS || unescaped === 0) return false;
return escaped / unescaped >= MINIMUM_ESCAPED_SHARE;
}
function unescapeEveryCharacter(text: string): string {
if (!hasEscapedEveryCharacter(text)) return text;
return text.replace(ESCAPED_CHARACTER_REGEX, '$1');
}
export const ESCAPED_EVERY_CHARACTER_RULE: NormalizationRule = {
name: 'escaped-every-character',
formats: ['po', 'mdx'],
detect: hasEscapedEveryCharacter,
fix: unescapeEveryCharacter,
};

View File

@@ -0,0 +1,40 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
const INLINE_CODE_SPAN_REGEX = /`[^`\n]+`/g;
const ESCAPED_TAG_REGEX =
/&lt;|&gt;|&#0*60;|&#0*62;|&#x0*3c;|&#x0*3e;|\\<|\\>/i;
function inlineCodeSpans(text: string): string[] {
return text.match(INLINE_CODE_SPAN_REGEX) ?? [];
}
function sourceHasTagInInlineCode(sourceText: string): boolean {
return inlineCodeSpans(sourceText).some(
(span) => span.includes('<') || span.includes('>'),
);
}
function hasEscapedTagInInlineCode(text: string): boolean {
return inlineCodeSpans(text).some((span) => ESCAPED_TAG_REGEX.test(span));
}
function unescapeTagsInInlineCode(text: string): string {
return text.replace(INLINE_CODE_SPAN_REGEX, (span) =>
span
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&#0*60;|&#x0*3c;/gi, '<')
.replace(/&#0*62;|&#x0*3e;/gi, '>')
.replace(/\\</g, '<')
.replace(/\\>/g, '>'),
);
}
export const ESCAPED_INLINE_CODE_TAGS_RULE: NormalizationRule = {
name: 'escaped-inline-code-tags',
formats: ['po', 'mdx'],
detect: hasEscapedTagInInlineCode,
fix: unescapeTagsInInlineCode,
sourceFilter: sourceHasTagInInlineCode,
};

View File

@@ -0,0 +1,35 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
const ESCAPED_UNICODE_REGEX = /\\u([0-9a-fA-F]{4})/g;
function escapesIn(text: string): string[] {
return [...text.matchAll(ESCAPED_UNICODE_REGEX)].map(([escape]) => escape);
}
// A source that writes the escape itself - a code sample, a string about
// escaping - means the translation is right to carry it through. Only an escape
// the translator introduced is a decoding failure, and without the source there
// is no way to tell the two apart, so the rule stands down as its peers do.
function hasEscapedUnicode(text: string, sourceText?: string): boolean {
if (sourceText === undefined) return false;
return escapesIn(text).some((escape) => !sourceText.includes(escape));
}
function unescapeUnicode(text: string, sourceText?: string): string {
if (sourceText === undefined) return text;
return text.replace(ESCAPED_UNICODE_REGEX, (escape, hex) =>
sourceText.includes(escape)
? escape
: String.fromCharCode(parseInt(hex, 16)),
);
}
export const ESCAPED_UNICODE_RULE: NormalizationRule = {
name: 'escaped-unicode',
formats: ['po'],
needsSourceText: true,
detect: hasEscapedUnicode,
fix: unescapeUnicode,
};

View File

@@ -0,0 +1,85 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
const ARGUMENT_NAME_REGEX = /^\{\s*([A-Za-z0-9_]+)\s*[,}]/;
// Arguments the message itself takes, ignoring plural and select case bodies:
// a translation may add cases its locale needs, but never a new argument, which
// the caller has no value for.
function topLevelArgumentNames(text: string): Set<string> {
const names = new Set<string>();
let depth = 0;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (character === '{') {
if (depth === 0) {
const match = ARGUMENT_NAME_REGEX.exec(text.slice(index));
if (match !== null) names.add(match[1]);
}
depth += 1;
continue;
}
if (character === '}') depth -= 1;
}
return names;
}
function inventedArgumentNames(text: string, sourceText: string): string[] {
const sourceNames = topLevelArgumentNames(sourceText);
return [...topLevelArgumentNames(text)].filter(
(name) => !sourceNames.has(name),
);
}
function renameArgument(text: string, from: string, to: string): string {
return text.replace(new RegExp(`\\{\\s*${from}\\s*(?=[,}])`, 'g'), `{${to}`);
}
function hasInventedArgument(text: string, sourceText?: string): boolean {
return (
sourceText !== undefined &&
inventedArgumentNames(text, sourceText).length > 0
);
}
function repairInventedArgument(text: string, sourceText?: string): string {
const source = sourceText ?? '';
const sourceNames = [...topLevelArgumentNames(source)];
const repairedText = inventedArgumentNames(text, source).reduce(
(accumulator, inventedName) => {
// Only a difference in spelling can be repaired: the argument is the same
// one, so restoring the source's casing makes it resolve again.
const intendedName = sourceNames.find(
(name) =>
name.toLowerCase() === inventedName.toLowerCase() &&
!topLevelArgumentNames(accumulator).has(name),
);
return intendedName === undefined
? accumulator
: renameArgument(accumulator, inventedName, intendedName);
},
text,
);
// An argument the source never had has no value to render, so an unrepaired
// translation is dropped rather than shipped with a dangling placeholder.
return inventedArgumentNames(repairedText, source).length > 0
? ''
: repairedText;
}
export const INVENTED_ARGUMENT_RULE: NormalizationRule = {
name: 'invented-argument',
formats: ['po'],
needsSourceText: true,
detect: hasInventedArgument,
fix: repairInventedArgument,
};

View File

@@ -0,0 +1,32 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
// Any markup tag, including Lingui's numbered ones (<0>), so a source carrying
// tags of its own is left entirely alone.
const TAG_PATTERN = '<\\/?[A-Za-z0-9][^<>]*>';
const TAG_REGEX = new RegExp(TAG_PATTERN);
const ALL_TAGS_REGEX = new RegExp(TAG_PATTERN, 'g');
// The machine translator sometimes decorates a plain string with markup of its
// own - RTL spans, bold around numbers, highlight spans - which the reader then
// sees as literal tags.
function hasInventedMarkup(text: string, sourceText?: string): boolean {
return (
sourceText !== undefined &&
!TAG_REGEX.test(sourceText) &&
TAG_REGEX.test(text)
);
}
// Stripping everything leaves nothing to say, and the empty result drops the
// translation rather than shipping a blank string.
function removeInventedMarkup(text: string): string {
return text.replace(ALL_TAGS_REGEX, '').trim();
}
export const INVENTED_MARKUP_RULE: NormalizationRule = {
name: 'invented-markup',
formats: ['po'],
needsSourceText: true,
detect: hasInventedMarkup,
fix: removeInventedMarkup,
};

View File

@@ -0,0 +1,70 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
// Backticks in the docs carry two different things: symbols the machine
// resolves, and UI labels the reader sees translated in their own app. Only the
// first kind is restored - a span shaped like an identifier, never one shaped
// like prose - so `useEffect` and `SELECT` are put back while `Settings` and
// `Data Model` stay in the reader's language.
const INLINE_CODE_REGEX = /`[^`\n]+`/g;
function codeSpansIn(text: string): string[] {
return [...text.matchAll(new RegExp(INLINE_CODE_REGEX))].map(
([match]) => match,
);
}
// Package, command and header names are hyphenated lowercase, and that is the
// shape translators actually broke: the French docs ship `vingt-emails` and the
// Japanese `20-emails` for `twenty-emails`, each inside an npx command nobody
// can run. Nothing in the English source has this shape without being a symbol.
const KEBAB_CASE_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)+$/;
function looksLikeIdentifier(span: string): boolean {
const code = span.slice(1, -1);
if (/\s/.test(code)) return false;
return (
/[a-z][A-Z]/.test(code) ||
/[_./(]/.test(code) ||
/^[A-Z0-9_]+$/.test(code) ||
KEBAB_CASE_REGEX.test(code)
);
}
// Only a message carrying exactly one span on each side is repaired. Lining
// spans up by position looked obvious and is wrong: a translation reorders them
// to suit its own grammar, so "the `twenty-app` keyword in your `package.json`
// `keywords` array" comes back with keywords and package.json swapped, and
// restoring by position writes package.json over both. With one span there is
// no order to get wrong.
function restoreIdentifiers(text: string, sourceText?: string): string {
if (sourceText === undefined) return text;
const sourceSpans = codeSpansIn(sourceText);
const targetSpans = codeSpansIn(text);
if (
sourceSpans.length !== 1 ||
targetSpans.length !== 1 ||
!looksLikeIdentifier(sourceSpans[0])
) {
return text;
}
return text.replace(INLINE_CODE_REGEX, sourceSpans[0]);
}
function hasTranslatedIdentifier(text: string, sourceText?: string): boolean {
if (sourceText === undefined) return false;
return restoreIdentifiers(text, sourceText) !== text;
}
export const TRANSLATED_IDENTIFIER_RULE: NormalizationRule = {
name: 'translated-identifier',
formats: ['mdx'],
needsSourceText: true,
detect: hasTranslatedIdentifier,
fix: restoreIdentifiers,
};

View File

@@ -0,0 +1,38 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
// \w is ASCII-only in JavaScript, which would read every Chinese, Arabic or
// Hebrew translation as wordless. Only the Unicode letter and number categories
// hold across the scripts we translate into.
const WORD_CHARACTER_REGEX = /[\p{L}\p{N}]/u;
function hasWordCharacter(text: string): boolean {
return WORD_CHARACTER_REGEX.test(text);
}
// A translation carrying no letter or digit at all cannot be a rendering of a
// source that carries them - it is a stray backslash, an escaped quote pair,
// punctuation debris. A source with no words of its own, such as a lone
// placeholder or a separator, is left alone: there a wordless translation is
// the correct one.
function isWordlessTranslation(text: string, sourceText?: string): boolean {
// Rules run in sequence on each other's output, so an empty text means an
// earlier rule already dropped this translation: firing again would only
// report the same deletion twice.
if (sourceText === undefined || text === '') return false;
return hasWordCharacter(sourceText) && !hasWordCharacter(text);
}
// Debris holds nothing to rebuild the missing words from, so the translation is
// dropped and the string falls back to English until Crowdin retranslates it.
function dropTranslation(): string {
return '';
}
export const WORDLESS_TRANSLATION_RULE: NormalizationRule = {
name: 'wordless-translation',
formats: ['po'],
needsSourceText: true,
detect: isWordlessTranslation,
fix: dropTranslation,
};

View File

@@ -0,0 +1,4 @@
export type CrowdinContext = {
token: string;
projectId: number;
};

View File

@@ -0,0 +1,5 @@
export type CrowdinTranslation = {
stringId: number;
translationId: number;
text: string;
};

View File

@@ -0,0 +1,17 @@
// Catalogs Crowdin holds for Twenty: Lingui PO for the app and website, MDX for
// the docs. ICU braces mean something different in each, so rules declare which
// they are safe to run against rather than being opted in per workflow.
export type CatalogFormat = 'po' | 'mdx';
export type NormalizationRule = {
name: string;
formats: CatalogFormat[];
detect: (text: string, sourceText?: string) => boolean;
// Returning an empty string means the translation could not be salvaged and
// should be deleted rather than replaced.
fix: (text: string, sourceText?: string) => string;
// Set by rules that compare a translation against its source but select every
// string rather than narrowing with sourceFilter.
needsSourceText?: boolean;
sourceFilter?: (sourceText: string) => boolean;
};

View File

@@ -0,0 +1,49 @@
import { NORMALIZATION_RULES } from '../../constants/normalization-rules.constant';
import { evaluateRules } from '../evaluate-rules.util';
describe('evaluateRules', () => {
it('reports every rule that fired and applies them in sequence', () => {
const result = evaluateRules({
rules: NORMALIZATION_RULES,
sourceText: 'Replace `<path>` with your path',
translationText: '\\u62db `&lt;path&gt;`',
});
expect(result.fixedText).toBe('招 `<path>`');
expect(result.ruleNames).toEqual([
'escaped-unicode',
'escaped-inline-code-tags',
]);
});
it('skips source-filtered rules when the source string is unknown', () => {
const result = evaluateRules({
rules: NORMALIZATION_RULES,
sourceText: undefined,
translationText: 'Remplacez `&lt;path&gt;` par votre chemin',
});
expect(result.ruleNames).toEqual([]);
});
it('skips source-filtered rules when the source does not match', () => {
const result = evaluateRules({
rules: NORMALIZATION_RULES,
sourceText: 'Escape a literal &lt; in prose',
translationText: 'Échappez un &lt; littéral dans le texte',
});
expect(result.ruleNames).toEqual([]);
});
it('returns the translation untouched when no rule fires', () => {
const result = evaluateRules({
rules: NORMALIZATION_RULES,
sourceText: 'Replace `<path>` with your path',
translationText: 'Remplacez `<path>` par votre chemin',
});
expect(result.fixedText).toBe('Remplacez `<path>` par votre chemin');
expect(result.ruleNames).toEqual([]);
});
});

View File

@@ -0,0 +1,67 @@
import { CrowdinApiError } from '../../errors/crowdin-api.error';
import { isTranslationAlreadyPresentError } from '../is-translation-already-present-error.util';
const buildError = (body: string) => new CrowdinApiError(400, body);
const buildDetailError = (detail: { code: string; message: string }) =>
buildError(
JSON.stringify({
errors: [{ error: { key: 'translation', errors: [detail] } }],
}),
);
describe('isTranslationAlreadyPresentError', () => {
it('recognises the identical-translation error code', () => {
expect(
isTranslationAlreadyPresentError(
buildDetailError({
code: 'identicalTranslation',
message: 'Identical translation already saved',
}),
),
).toBe(true);
});
it('recognises the duplicate-translation validation error', () => {
expect(
isTranslationAlreadyPresentError(
buildDetailError({
code: 'validationError',
message:
'Duplicate translation. Please vote or approve the original.',
}),
),
).toBe(true);
});
it('does not swallow an unrelated validation error', () => {
expect(
isTranslationAlreadyPresentError(
buildDetailError({
code: 'validationError',
message: 'Translation is too long',
}),
),
).toBe(false);
});
it('does not treat another failure as present because of its wording', () => {
expect(
isTranslationAlreadyPresentError(
buildDetailError({
code: 'notFound',
message: 'String is identical to a deleted one',
}),
),
).toBe(false);
});
it('rejects a non-JSON body and any non-Crowdin error', () => {
expect(
isTranslationAlreadyPresentError(buildError('<html>502</html>')),
).toBe(false);
expect(isTranslationAlreadyPresentError(new Error('identical'))).toBe(
false,
);
});
});

View File

@@ -0,0 +1,51 @@
import { mapWithConcurrency } from '../map-with-concurrency.util';
describe('mapWithConcurrency', () => {
it('keeps results in input order regardless of completion order', async () => {
const results = await mapWithConcurrency({
items: [30, 10, 20],
limit: 3,
handler: (delay) =>
new Promise<number>((resolve) =>
setTimeout(() => resolve(delay), delay),
),
});
expect(results).toEqual([30, 10, 20]);
});
it('never runs more than `limit` handlers at once', async () => {
let running = 0;
let peak = 0;
await mapWithConcurrency({
items: Array.from({ length: 20 }, (_, index) => index),
limit: 3,
handler: async (item) => {
running++;
peak = Math.max(peak, running);
await new Promise((resolve) => setTimeout(resolve, 1));
running--;
return item;
},
});
expect(peak).toBe(3);
});
it('rejects a limit that would leave every item unhandled', async () => {
await expect(
mapWithConcurrency({ items: [1], limit: 0, handler: jest.fn() }),
).rejects.toThrow('at least 1');
});
it('returns an empty array without spawning workers', async () => {
const handler = jest.fn();
expect(await mapWithConcurrency({ items: [], limit: 5, handler })).toEqual(
[],
);
expect(handler).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,25 @@
import { type CrowdinContext } from '../types/crowdin-context.type';
import { crowdinRequest } from './crowdin-request.util';
import { isTranslationAlreadyPresentError } from './is-translation-already-present-error.util';
export async function addTranslation(
context: CrowdinContext,
{
stringId,
languageId,
text,
}: { stringId: number; languageId: string; text: string },
): Promise<void> {
try {
await crowdinRequest(
context,
`/projects/${context.projectId}/translations`,
{
method: 'POST',
body: JSON.stringify({ stringId, languageId, text }),
},
);
} catch (error) {
if (!isTranslationAlreadyPresentError(error)) throw error;
}
}

View File

@@ -0,0 +1,31 @@
import { CrowdinApiError } from '../errors/crowdin-api.error';
import { type CrowdinContext } from '../types/crowdin-context.type';
const CROWDIN_BASE_URL = 'https://twenty.api.crowdin.com/api/v2';
export async function crowdinRequest<T>(
{ token }: CrowdinContext,
endpoint: string,
options: RequestInit = {},
): Promise<T | undefined> {
const response = await fetch(`${CROWDIN_BASE_URL}${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers,
},
});
const body = await response.text();
if (!response.ok) {
if (response.status === 404) return undefined;
throw new CrowdinApiError(response.status, body);
}
if (!body) return undefined;
return JSON.parse(body) as T;
}

View File

@@ -0,0 +1,13 @@
import { type CrowdinContext } from '../types/crowdin-context.type';
import { crowdinRequest } from './crowdin-request.util';
export async function deleteTranslation(
context: CrowdinContext,
{ translationId }: { translationId: number },
): Promise<void> {
await crowdinRequest(
context,
`/projects/${context.projectId}/translations/${translationId}`,
{ method: 'DELETE' },
);
}

View File

@@ -0,0 +1,29 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
export function evaluateRules({
rules,
sourceText,
translationText,
}: {
rules: NormalizationRule[];
sourceText: string | undefined;
translationText: string;
}): { fixedText: string; ruleNames: string[] } {
return rules.reduce<{ fixedText: string; ruleNames: string[] }>(
(accumulator, rule) => {
const isFilteredOut =
rule.sourceFilter !== undefined &&
(sourceText === undefined || !rule.sourceFilter(sourceText));
if (isFilteredOut || !rule.detect(accumulator.fixedText, sourceText)) {
return accumulator;
}
return {
fixedText: rule.fix(accumulator.fixedText, sourceText),
ruleNames: [...accumulator.ruleNames, rule.name],
};
},
{ fixedText: translationText, ruleNames: [] },
);
}

View File

@@ -0,0 +1,31 @@
import { type CrowdinContext } from '../types/crowdin-context.type';
import { crowdinRequest } from './crowdin-request.util';
const PAGE_SIZE = 500;
export async function fetchAllPages<TItem>(
context: CrowdinContext,
buildEndpoint: (pagination: string) => string,
): Promise<TItem[]> {
const items: TItem[] = [];
let offset = 0;
while (true) {
type PagedResponse = { data: Array<{ data: TItem }> };
const response = await crowdinRequest<PagedResponse>(
context,
buildEndpoint(`limit=${PAGE_SIZE}&offset=${offset}`),
);
if (!response || response.data.length === 0) break;
items.push(...response.data.map((item) => item.data));
if (response.data.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
return items;
}

View File

@@ -0,0 +1,47 @@
import { type CrowdinContext } from '../types/crowdin-context.type';
import { type CrowdinTranslation } from '../types/crowdin-translation.type';
import { fetchAllPages } from './fetch-all-pages.util';
const MAX_STRING_IDS_PER_REQUEST = 50;
function chunkStringIds(stringIds: number[]): number[][] {
const batches: number[][] = [];
for (
let index = 0;
index < stringIds.length;
index += MAX_STRING_IDS_PER_REQUEST
) {
batches.push(stringIds.slice(index, index + MAX_STRING_IDS_PER_REQUEST));
}
return batches;
}
export async function fetchLanguageTranslations(
context: CrowdinContext,
{ languageId, stringIds }: { languageId: string; stringIds?: number[] },
): Promise<CrowdinTranslation[]> {
const stringIdBatches = stringIds ? chunkStringIds(stringIds) : [undefined];
const translations: Array<Partial<CrowdinTranslation>> = [];
for (const batch of stringIdBatches) {
const stringIdsFilter = batch ? `&stringIds=${batch.join(',')}` : '';
translations.push(
...(await fetchAllPages<Partial<CrowdinTranslation>>(
context,
(pagination) =>
`/projects/${context.projectId}/languages/${languageId}/translations?${pagination}${stringIdsFilter}`,
)),
);
}
return translations.filter(
(translation): translation is CrowdinTranslation =>
typeof translation.stringId === 'number' &&
typeof translation.translationId === 'number' &&
typeof translation.text === 'string',
);
}

View File

@@ -0,0 +1,26 @@
import { type CrowdinContext } from '../types/crowdin-context.type';
import { fetchAllPages } from './fetch-all-pages.util';
export async function fetchSourceStringsById(
context: CrowdinContext,
): Promise<Map<number, string>> {
type SourceString = {
id?: number;
text?: string | Record<string, string>;
};
const sourceStrings = await fetchAllPages<SourceString>(
context,
(pagination) => `/projects/${context.projectId}/strings?${pagination}`,
);
return new Map(
sourceStrings
.filter(
(sourceString): sourceString is { id: number; text: string } =>
typeof sourceString.id === 'number' &&
typeof sourceString.text === 'string',
)
.map((sourceString) => [sourceString.id, sourceString.text]),
);
}

View File

@@ -0,0 +1,15 @@
import { type CrowdinContext } from '../types/crowdin-context.type';
import { crowdinRequest } from './crowdin-request.util';
export async function fetchTargetLanguageIds(
context: CrowdinContext,
): Promise<string[]> {
type ProjectResponse = { data: { targetLanguageIds: string[] } };
const response = await crowdinRequest<ProjectResponse>(
context,
`/projects/${context.projectId}`,
);
return response?.data.targetLanguageIds ?? [];
}

View File

@@ -0,0 +1,11 @@
export function getCrowdinTokenOrThrow(): string {
const token = process.env.CROWDIN_PERSONAL_TOKEN;
if (!token) {
throw new Error(
'CROWDIN_PERSONAL_TOKEN environment variable not set. Get your token from: https://twenty.crowdin.com/u/settings#api-key',
);
}
return token;
}

View File

@@ -0,0 +1,33 @@
import { CrowdinApiError } from '../errors/crowdin-api.error';
const IDENTICAL_TRANSLATION_CODE = 'identicalTranslation';
const VALIDATION_ERROR_CODE = 'validationError';
// validationError also covers unrelated refusals, so the wording has to narrow
// it down to the one that means the text we wanted is already there.
const DUPLICATE_TRANSLATION_REGEX = /duplicate translation/i;
type CrowdinErrorBody = {
errors?: Array<{
error?: { errors?: Array<{ code?: string; message?: string }> };
}>;
};
export function isTranslationAlreadyPresentError(error: unknown): boolean {
if (!(error instanceof CrowdinApiError)) return false;
try {
const body = JSON.parse(error.body) as CrowdinErrorBody;
return (body.errors ?? []).some((entry) =>
(entry.error?.errors ?? []).some(
(detail) =>
detail.code === IDENTICAL_TRANSLATION_CODE ||
(detail.code === VALIDATION_ERROR_CODE &&
DUPLICATE_TRANSLATION_REGEX.test(detail.message ?? '')),
),
);
} catch {
return false;
}
}

View File

@@ -0,0 +1,32 @@
export async function mapWithConcurrency<TItem, TResult>({
items,
limit,
handler,
}: {
items: TItem[];
limit: number;
handler: (item: TItem) => Promise<TResult>;
}): Promise<TResult[]> {
if (limit < 1) {
throw new Error(
`mapWithConcurrency needs a limit of at least 1, got ${limit}`,
);
}
const results: TResult[] = Array.from({ length: items.length });
let cursor = 0;
const worker = async () => {
while (cursor < items.length) {
const index = cursor++;
results[index] = await handler(items[index]);
}
};
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, worker),
);
return results;
}