From 3dbd9bdccfaba99945ba9209823b97dd129ca8ea Mon Sep 17 00:00:00 2001 From: TheFlow Date: Sun, 26 Oct 2025 01:31:59 +1300 Subject: [PATCH] feat(i18n): add translation export/import scripts for production deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added Scripts: - export-translations.js: Export all translations from MongoDB to JSON - import-translations.js: Import translations into production database Purpose: - Avoid re-running DeepL API on production (saves quota) - Enable dev-to-prod translation deployment workflow - Support dry-run and force-overwrite modes Usage: - Export: node scripts/export-translations.js /tmp/translations-export.json - Import: node scripts/import-translations.js /tmp/translations-export.json Deployment Workflow: 1. Export translations from dev 2. Deploy code to production via deploy.sh 3. Copy export file to production 4. Import translations on production 🌐 Generated with Claude Code Co-Authored-By: Claude --- scripts/export-translations.js | 96 ++++++++++++++++++++ scripts/import-translations.js | 156 +++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 scripts/export-translations.js create mode 100644 scripts/import-translations.js diff --git a/scripts/export-translations.js b/scripts/export-translations.js new file mode 100644 index 00000000..62adab8b --- /dev/null +++ b/scripts/export-translations.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +/** + * Export Translations Script + * + * Exports all translations from the local database to a JSON file + * for deployment to production without re-running DeepL API + * + * Usage: + * node scripts/export-translations.js [output-file] + * + * Default output: /tmp/translations-export.json + */ + +require('dotenv').config(); +const mongoose = require('mongoose'); +const fs = require('fs'); +const path = require('path'); + +const Document = require('../src/models/Document.model'); + +async function main() { + const outputFile = process.argv[2] || '/tmp/translations-export.json'; + + console.log('═══════════════════════════════════════════════════════════'); + console.log(' EXPORT TRANSLATIONS'); + console.log('═══════════════════════════════════════════════════════════\n'); + + // Connect to MongoDB + console.log('šŸ“” Connecting to MongoDB...'); + await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/tractatus_dev', { + serverSelectionTimeoutMS: 5000 + }); + console.log('āœ“ Connected\n'); + + // Fetch all documents with translations + console.log('šŸ“š Fetching documents with translations...'); + const documents = await Document.list({ + filter: { visibility: 'public' }, + limit: 1000, + sort: { order: 1 } + }); + + const exportData = { + exported_at: new Date().toISOString(), + source_database: process.env.MONGODB_URI || 'mongodb://localhost:27017/tractatus_dev', + total_documents: documents.length, + documents: [] + }; + + let totalTranslations = 0; + + for (const doc of documents) { + if (doc.translations && Object.keys(doc.translations).length > 0) { + const docExport = { + slug: doc.slug, + _id: doc._id.toString(), + translations: doc.translations + }; + + exportData.documents.push(docExport); + + const langCount = Object.keys(doc.translations).length; + totalTranslations += langCount; + console.log(` āœ“ ${doc.slug}: ${langCount} translation(s)`); + } + } + + exportData.total_translations = totalTranslations; + + // Write to file + console.log(`\nšŸ’¾ Writing to ${outputFile}...`); + fs.writeFileSync(outputFile, JSON.stringify(exportData, null, 2), 'utf8'); + console.log('āœ“ Export complete\n'); + + // Summary + console.log('═══════════════════════════════════════════════════════════'); + console.log(' EXPORT SUMMARY'); + console.log('═══════════════════════════════════════════════════════════\n'); + console.log(` Documents with translations: ${exportData.documents.length}`); + console.log(` Total translations: ${totalTranslations}`); + console.log(` Output file: ${outputFile}`); + console.log(` File size: ${(fs.statSync(outputFile).size / 1024).toFixed(2)} KB\n`); + + await mongoose.disconnect(); + console.log('āœ“ Database disconnected\n'); + + process.exit(0); +} + +// Run +main().catch(err => { + console.error('\nāŒ Fatal error:', err.message); + console.error(err.stack); + process.exit(1); +}); diff --git a/scripts/import-translations.js b/scripts/import-translations.js new file mode 100644 index 00000000..de145196 --- /dev/null +++ b/scripts/import-translations.js @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +/** + * Import Translations Script + * + * Imports translations from a JSON export file into the database + * Used to deploy translations from dev to production + * + * Usage: + * node scripts/import-translations.js + * + * Options: + * --dry-run Preview import without making changes + * --force Overwrite existing translations + */ + +require('dotenv').config(); +const mongoose = require('mongoose'); +const fs = require('fs'); + +const Document = require('../src/models/Document.model'); + +// Parse arguments +const args = process.argv.slice(2); +const options = { + dryRun: args.includes('--dry-run'), + force: args.includes('--force'), + inputFile: args.find(arg => !arg.startsWith('--')) +}; + +if (!options.inputFile) { + console.error('āŒ ERROR: Input file required'); + console.error('Usage: node scripts/import-translations.js [--dry-run] [--force]'); + process.exit(1); +} + +async function main() { + console.log('═══════════════════════════════════════════════════════════'); + console.log(' IMPORT TRANSLATIONS'); + console.log('═══════════════════════════════════════════════════════════\n'); + + if (options.dryRun) { + console.log('šŸ” DRY-RUN MODE - No changes will be made\n'); + } + + // Load import file + console.log(`šŸ“ Loading ${options.inputFile}...`); + if (!fs.existsSync(options.inputFile)) { + console.error(`āŒ ERROR: File not found: ${options.inputFile}`); + process.exit(1); + } + + const importData = JSON.parse(fs.readFileSync(options.inputFile, 'utf8')); + console.log(`āœ“ Loaded export from ${importData.exported_at}`); + console.log(`āœ“ Source: ${importData.source_database}`); + console.log(`āœ“ Documents: ${importData.documents.length}`); + console.log(`āœ“ Translations: ${importData.total_translations}\n`); + + // Connect to MongoDB + console.log('šŸ“” Connecting to MongoDB...'); + await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/tractatus_dev', { + serverSelectionTimeoutMS: 5000 + }); + console.log(`āœ“ Connected to ${process.env.MONGODB_URI || 'mongodb://localhost:27017/tractatus_dev'}\n`); + + // Import each document + const stats = { + total: importData.documents.length, + imported: 0, + skipped: 0, + failed: 0, + errors: [] + }; + + console.log('šŸ“š Importing translations...\n'); + + for (const docData of importData.documents) { + try { + // Find document by slug (more reliable than _id across environments) + const doc = await Document.findBySlug(docData.slug); + + if (!doc) { + console.log(` ⚠ ${docData.slug}: Document not found, skipping`); + stats.skipped++; + continue; + } + + // Check if translations already exist + const hasExisting = doc.translations && Object.keys(doc.translations).length > 0; + + if (hasExisting && !options.force) { + console.log(` ā­ ${docData.slug}: Already has translations (use --force to overwrite)`); + stats.skipped++; + continue; + } + + if (options.dryRun) { + const langCount = Object.keys(docData.translations).length; + console.log(` šŸ” ${docData.slug}: Would import ${langCount} translation(s) ${hasExisting ? '(overwrite)' : '(new)'}`); + stats.imported++; + continue; + } + + // Import translations + await Document.update(doc._id.toString(), { + translations: docData.translations + }); + + const langCount = Object.keys(docData.translations).length; + console.log(` āœ“ ${docData.slug}: Imported ${langCount} translation(s)`); + stats.imported++; + + } catch (error) { + console.error(` āœ— ${docData.slug}: ${error.message}`); + stats.failed++; + stats.errors.push({ + slug: docData.slug, + error: error.message + }); + } + } + + // Summary + console.log('\n═══════════════════════════════════════════════════════════'); + console.log(' IMPORT SUMMARY'); + console.log('═══════════════════════════════════════════════════════════\n'); + + if (options.dryRun) { + console.log(' Dry run complete - no changes were made\n'); + } + + console.log(` Total documents: ${stats.total}`); + console.log(` Imported: ${stats.imported}`); + console.log(` Skipped: ${stats.skipped}`); + console.log(` Failed: ${stats.failed}\n`); + + if (stats.errors.length > 0) { + console.log(' Errors:'); + stats.errors.forEach(err => { + console.log(` - ${err.slug}: ${err.error}`); + }); + console.log(''); + } + + await mongoose.disconnect(); + console.log('āœ“ Database disconnected\n'); + + process.exit(stats.failed > 0 ? 1 : 0); +} + +// Run +main().catch(err => { + console.error('\nāŒ Fatal error:', err.message); + console.error(err.stack); + process.exit(1); +});