Files
upstream-twenty/packages/twenty-utils/crowdin-normalizer/normalize-crowdin-translations.ts
upstream-mirror e7da97194a
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
snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
2026-09-17 23:28:13 +05:00

296 lines
8.2 KiB
TypeScript

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);
});