#!/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 { 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(); 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 { 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 { 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 { 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 [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(); }