Files
web/scripts/i18n/updateIds/index.ts
Joakim Jäderberg aafad9781f Merged in feat/lokalise-rebuild (pull request #2993)
Feat/lokalise rebuild

* chore(lokalise): update translation ids

* chore(lokalise): easier to switch between projects

* chore(lokalise): update translation ids

* .

* .

* .

* .

* .

* .

* chore(lokalise): update translation ids

* chore(lokalise): update translation ids

* .

* .

* .

* chore(lokalise): update translation ids

* chore(lokalise): update translation ids

* .

* .

* chore(lokalise): update translation ids

* chore(lokalise): update translation ids

* chore(lokalise): new translations

* merge

* switch to errors for missing id's

* merge

* sync translations


Approved-by: Linus Flood
2025-10-22 11:00:03 +00:00

336 lines
10 KiB
JavaScript

#!/usr/bin/env node
import * as fs from "fs/promises";
import * as path from "path";
interface TranslationEntry {
translation: string;
}
interface TranslationFile {
[key: string]: TranslationEntry;
}
interface IdMapping {
oldId: string;
newId: string;
englishText: string;
}
/**
* Creates a normalized string representation of translation entries for comparison
* @param entries Array of translation entries
* @returns Normalized string for comparison
*/
function normalizeTranslationEntries(entries: TranslationEntry): string {
return JSON.stringify(entries);
}
/**
* Compares two translation files and creates a mapping of old IDs to new IDs
* @param oldFilePath Path to the file with autogenerated IDs
* @param newFilePath Path to the file with manually set IDs
* @returns Array of ID mappings
*/
export async function createIdMapping(
oldFilePath: string,
newFilePath: string
): Promise<IdMapping[]> {
try {
const oldFileContent = await fs.readFile(oldFilePath, "utf-8");
const newFileContent = await fs.readFile(newFilePath, "utf-8");
const oldTranslations: TranslationFile = JSON.parse(oldFileContent);
const newTranslations: TranslationFile = JSON.parse(newFileContent);
const mappings: IdMapping[] = [];
// Create a reverse lookup for new translations based on complete entry structure
const newTranslationsByText = new Map<string, string>();
for (const [newId, entries] of Object.entries(newTranslations)) {
const normalizedEntries = normalizeTranslationEntries(entries);
if (normalizedEntries && normalizedEntries.length > 0) {
newTranslationsByText.set(normalizedEntries, newId);
}
}
// Match old IDs with new IDs based on complete entry structure
for (const [oldId, entries] of Object.entries(oldTranslations)) {
const normalizedEntries = normalizeTranslationEntries(entries);
if (normalizedEntries && normalizedEntries.length > 0) {
const englishText = entries.translation; // Keep first entry for display purposes
const newId = newTranslationsByText.get(normalizedEntries);
if (newId && newId !== oldId) {
mappings.push({
oldId,
newId,
englishText,
});
}
}
}
return mappings;
} catch (error) {
console.error("Error creating ID mapping:", error);
throw error;
}
}
/**
* Updates a translation file by replacing old IDs with new IDs
* @param filePath Path to the translation file to update
* @param mappings Array of ID mappings
* @returns Number of replacements made
*/
export async function updateTranslationFile(
filePath: string,
mappings: IdMapping[]
): Promise<number> {
try {
const fileContent = await fs.readFile(filePath, "utf-8");
const translations: TranslationFile = JSON.parse(fileContent);
let replacementCount = 0;
const updatedTranslations: TranslationFile = {};
// Create a mapping lookup for efficient searching
const mappingLookup = new Map(mappings.map((m) => [m.oldId, m.newId]));
for (const [oldId, entries] of Object.entries(translations)) {
const newId = mappingLookup.get(oldId);
if (newId) {
updatedTranslations[newId] = entries;
replacementCount++;
console.log(` ${oldId}${newId}`);
} else {
updatedTranslations[oldId] = entries;
}
}
// Write the updated file with proper formatting
await fs.writeFile(
filePath,
JSON.stringify(updatedTranslations, null, 2) + "\n",
"utf-8"
);
return replacementCount;
} catch (error) {
console.error(`Error updating file ${filePath}:`, error);
throw error;
}
}
/**
* Updates multiple translation files with the same ID mappings
* @param mappings Array of ID mappings
* @param translationFilePaths Array of file paths to update
*/
export async function updateAllTranslationFiles(
mappings: IdMapping[],
translationFilePaths: string[]
): Promise<void> {
console.log(`Found ${mappings.length} ID mappings to apply`);
for (const filePath of translationFilePaths) {
try {
const fileName = path.basename(filePath);
console.log(`\nUpdating ${fileName}...`);
const replacementCount = await updateTranslationFile(
filePath,
mappings
);
console.log(
` ✓ Made ${replacementCount} replacements in ${fileName}`
);
} catch (error) {
console.error(
` ✗ Failed to update ${path.basename(filePath)}:`,
error
);
}
}
}
/**
* Validates that the mapping is correct by checking if the English text matches
* @param mappings Array of ID mappings
* @param oldFilePath Path to the old translation file
* @param newFilePath Path to the new translation file
*/
export async function validateMappings(
mappings: IdMapping[],
oldFilePath: string,
newFilePath: string
): Promise<{ validCount: number; invalidCount: number }> {
const oldTranslations: TranslationFile = JSON.parse(
await fs.readFile(oldFilePath, "utf-8")
);
const newTranslations: TranslationFile = JSON.parse(
await fs.readFile(newFilePath, "utf-8")
);
console.log("\nValidating mappings:");
let validCount = 0;
let invalidCount = 0;
for (const mapping of mappings) {
const oldEntry = oldTranslations[mapping.oldId];
const newEntry = newTranslations[mapping.newId];
if (!oldEntry || !newEntry) {
console.log(
` ✗ Missing entry for ${mapping.oldId}${mapping.newId}`
);
invalidCount++;
continue;
}
const oldNormalized = normalizeTranslationEntries(oldEntry);
const newNormalized = normalizeTranslationEntries(newEntry);
if (oldNormalized === newNormalized) {
validCount++;
} else {
console.log(
` ✗ Entry structure mismatch for ${mapping.oldId}${mapping.newId}`
);
console.log(` Old: ${JSON.stringify(oldEntry, null, 2)}`);
console.log(` New: ${JSON.stringify(newEntry, null, 2)}`);
invalidCount++;
}
}
console.log(
`\nValidation complete: ${validCount} valid, ${invalidCount} invalid mappings`
);
return { validCount, invalidCount };
}
/**
* Finds all translation files in a directory
* @param directory Directory to search in
* @returns Array of translation file paths
*/
export async function findTranslationFiles(
directory: string
): Promise<string[]> {
try {
const files = await fs.readdir(directory);
return files
.filter((file) => file.endsWith(".json"))
.map((file) => path.join(directory, file));
} catch (error) {
console.error(`Error reading directory ${directory}:`, error);
return [];
}
}
// CLI functionality
async function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
console.log(`
Usage:
npm run update-ids <old-en.json> <new-en.json> [translation-directory]
Examples:
# Create mapping and update files in the same directory
npm run update-ids ./old-en.json ./new-en.json
# Create mapping and update files in specific directory
npm run update-ids ./old-en.json ./new-en.json ./translations/
# Just create mapping (no updates)
npm run update-ids ./old-en.json ./new-en.json --dry-run
`);
process.exit(1);
}
const oldFilePath = path.resolve(args[0]);
const newFilePath = path.resolve(args[1]);
const isDryRun = args.includes("--dry-run");
// Determine translation directory
let translationDirectory: string;
if (args[2] && !args[2].startsWith("--")) {
translationDirectory = path.resolve(args[2]);
} else {
translationDirectory = path.dirname(newFilePath);
}
if (isDryRun) {
console.log("\n🔍 DRY RUN MODE");
}
try {
// Create the ID mappings
const mappings = await createIdMapping(oldFilePath, newFilePath);
if (mappings.length === 0) {
console.log(
"No ID mappings found. Files might already be synchronized."
);
return;
}
console.log("Mappings that will be applied:");
mappings.forEach((m) => {
if (!m.englishText) {
console.warn(
` ⚠️ Invalid entry for old ID ${m.oldId} ${m.newId}`
);
}
console.log(
` ${m.oldId}${m.newId} ("${m.englishText?.substring(0, 20)}...")`
);
});
// Validate mappings in dry-run mode (read-only)
const { invalidCount } = await validateMappings(
mappings,
oldFilePath,
newFilePath
);
if (isDryRun) {
console.log("\nExiting - No files were be modified");
return;
}
if (invalidCount > 0) {
console.log("\n✗ Aborting due to invalid mappings");
return;
}
// Find all translation files
const translationFiles =
await findTranslationFiles(translationDirectory);
const filesToUpdate = translationFiles.filter(
(f) => f !== newFilePath && f !== oldFilePath
);
if (filesToUpdate.length === 0) {
console.log("No translation files found to update.");
return;
}
// Update all translation files
await updateAllTranslationFiles(mappings, filesToUpdate);
console.log("\n✓ All translation files updated successfully!");
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
// Run CLI if this file is executed directly
if (require.main === module) {
main();
}