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
296 lines
8.2 KiB
TypeScript
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);
|
|
});
|