文件预览

replace-image.js

查看 微信小游戏本地化Skill 技能包中的文件内容。

文件内容

scripts/replace-image.js

#!/usr/bin/env node
/**
 * 图片替换脚本 v2
 * 
 * 基于 image_translations.json,将翻译后的图片复制到项目中
 * 替换原始图片或按语言目录组织。
 * 
 * v2 改进:
 * 1. 智能搜索 MCP 翻译后的图片(支持多种目录结构)
 * 2. 替换前后校验图片文件完整性(文件大小 > 0)
 * 3. 生成详细替换报告
 * 
 * 使用方法:
 *   node replace-image.js --project <projectRoot> [--mode replace|copy] [--dry-run]
 * 
 * 参数:
 *   --project      项目根目录路径
 *   --mode         替换模式:replace(直接替换原图)/ copy(复制到语言目录)
 *   --dry-run      预览模式
 *   --target-lang  目标语言代码
 *   --backup       替换前备份原始图片(默认开启)
 */

const fs = require('fs');
const path = require('path');

// ============================================================
// 配置
// ============================================================

const args = parseArgs(process.argv.slice(2));
const PROJECT_ROOT = args.project || process.cwd();
const MODE = args.mode || 'replace'; // replace | copy
const DRY_RUN = args['dry-run'] || false;
const TARGET_LANG = args['target-lang'] || 'en';
const BACKUP = args.backup !== false;

const I18N_DIR = path.join(PROJECT_ROOT, 'i18n');
const IMAGE_TRANSLATIONS_FILE = path.join(I18N_DIR, 'image_translations.json');
const TRANSLATED_ASSETS_DIR = path.join(I18N_DIR, 'assets', TARGET_LANG);
const BACKUP_DIR = path.join(I18N_DIR, 'backups', new Date().toISOString().replace(/[:.]/g, '-'), 'images');

// ============================================================
// 翻译图片查找
// ============================================================

/**
 * 智能查找 MCP 翻译后的图片路径
 * MCP 翻译结果的目录结构为:
 *   i18n/assets/{lang}/{上传时路径前缀}/files/{项目相对路径}/{文件名}
 * 或:
 *   i18n/assets/{lang}/{项目相对路径}/{文件名}
 * 
 * 我们需要在多个候选路径中查找
 */
function findTranslatedImage(sourceFile, targetFile) {
  // 候选路径列表,按优先级排序
  const candidates = [];

  // 1. 如果 targetFile 已经明确指定,优先使用
  if (targetFile) {
    candidates.push(path.join(PROJECT_ROOT, targetFile));
  }

  // 2. 标准目录:i18n/assets/{lang}/{sourceFile}
  candidates.push(path.join(TRANSLATED_ASSETS_DIR, sourceFile));

  // 3. MCP 翻译输出格式:可能带有 upload 前缀
  //    例如 i18n/assets/en/upload_image_xxx/files/assets/img/btn.png
  //    我们需要递归搜索同名文件
  const sourceBasename = path.basename(sourceFile);
  const sourceDir = path.dirname(sourceFile);

  // 4. 在 translated_output 目录中查找(MCP 下载解压的目录)
  const translatedOutputDir = path.join(I18N_DIR, 'translated_output', TARGET_LANG);
  if (fs.existsSync(translatedOutputDir)) {
    const found = findFileRecursive(translatedOutputDir, sourceBasename, sourceDir);
    if (found) candidates.push(found);
  }

  // 5. 在 assets/{lang} 中递归搜索同名文件
  if (fs.existsSync(TRANSLATED_ASSETS_DIR)) {
    const found = findFileRecursive(TRANSLATED_ASSETS_DIR, sourceBasename, sourceDir);
    if (found) candidates.push(found);
  }

  // 返回第一个存在的路径
  for (const candidate of candidates) {
    if (fs.existsSync(candidate)) {
      const stat = fs.statSync(candidate);
      if (stat.size > 0) {
        return candidate;
      }
    }
  }

  return null;
}

/**
 * 在目录中递归查找文件
 * 优先匹配路径最相似的(考虑 sourceDir 的后缀匹配)
 */
function findFileRecursive(dir, filename, sourceDir) {
  const results = [];

  function walk(currentDir) {
    try {
      const items = fs.readdirSync(currentDir);
      for (const item of items) {
        const fullPath = path.join(currentDir, item);
        try {
          const stat = fs.statSync(fullPath);
          if (stat.isDirectory()) {
            walk(fullPath);
          } else if (item === filename) {
            results.push(fullPath);
          }
        } catch (e) { /* skip inaccessible */ }
      }
    } catch (e) { /* skip inaccessible */ }
  }

  walk(dir);

  if (results.length === 0) return null;
  if (results.length === 1) return results[0];

  // 多个匹配时,选择路径后缀最匹配 sourceDir 的
  const sourceDirParts = sourceDir.split(path.sep).filter(Boolean);
  let bestMatch = results[0];
  let bestScore = 0;

  for (const result of results) {
    const relPath = path.dirname(result);
    const parts = relPath.split(path.sep).filter(Boolean);
    let score = 0;
    // 从后往前比较路径片段
    for (let i = 1; i <= Math.min(sourceDirParts.length, parts.length); i++) {
      if (sourceDirParts[sourceDirParts.length - i] === parts[parts.length - i]) {
        score++;
      } else {
        break;
      }
    }
    if (score > bestScore) {
      bestScore = score;
      bestMatch = result;
    }
  }

  return bestMatch;
}

// ============================================================
// 主流程
// ============================================================

async function main() {
  console.log('🖼️  图片替换工具 v2');
  console.log(`   项目路径: ${PROJECT_ROOT}`);
  console.log(`   目标语言: ${TARGET_LANG}`);
  console.log(`   替换模式: ${MODE === 'replace' ? '直接替换' : '复制到语言目录'}`);
  console.log(`   预览模式: ${DRY_RUN ? '是' : '否'}`);
  console.log('');

  // 1. 读取图片翻译表
  if (!fs.existsSync(IMAGE_TRANSLATIONS_FILE)) {
    console.error(`❌ 图片翻译表不存在: ${IMAGE_TRANSLATIONS_FILE}`);
    process.exit(1);
  }
  const imageTranslations = JSON.parse(fs.readFileSync(IMAGE_TRANSLATIONS_FILE, 'utf8'));
  const entries = imageTranslations.translations.filter(e => e.status === 'completed');
  console.log(`✅ 读取图片翻译表: ${entries.length} 张已完成翻译`);
  console.log('');

  // 2. 逐张替换
  let totalReplaced = 0;
  let totalFailed = 0;
  const results = [];

  for (const entry of entries) {
    const { sourceFile, targetFile, key } = entry;

    // 智能查找翻译后的图片
    const translatedPath = findTranslatedImage(sourceFile, targetFile);

    // 检查翻译后的图片是否存在
    if (!translatedPath) {
      console.warn(`⚠️  翻译后图片不存在: ${sourceFile}`);
      console.warn(`   已搜索路径:`);
      console.warn(`   - ${targetFile ? path.join(PROJECT_ROOT, targetFile) : '(无指定)'}`);
      console.warn(`   - ${path.join(TRANSLATED_ASSETS_DIR, sourceFile)}`);
      console.warn(`   - ${path.join(I18N_DIR, 'translated_output', TARGET_LANG)} (递归)`);
      totalFailed++;
      results.push({ key, sourceFile, status: 'missing_target', searchPaths: [
        targetFile, path.join('i18n/assets', TARGET_LANG, sourceFile)
      ]});
      continue;
    }

    const sourcePath = path.join(PROJECT_ROOT, sourceFile);

    if (MODE === 'replace') {
      // 直接替换模式
      if (!fs.existsSync(sourcePath)) {
        console.warn(`⚠️  源图片不存在: ${sourceFile}`);
        totalFailed++;
        results.push({ key, sourceFile, status: 'missing_source' });
        continue;
      }

      // 校验翻译后的图片文件大小
      const translatedStat = fs.statSync(translatedPath);
      if (translatedStat.size === 0) {
        console.warn(`⚠️  翻译后图片为空文件: ${translatedPath}`);
        totalFailed++;
        results.push({ key, sourceFile, status: 'empty_target' });
        continue;
      }

      if (BACKUP && !DRY_RUN) {
        backupFile(sourcePath, sourceFile);
      }

      if (!DRY_RUN) {
        fs.copyFileSync(translatedPath, sourcePath);

        // 替换后校验:确认文件已正确写入
        const replacedStat = fs.statSync(sourcePath);
        if (replacedStat.size === 0) {
          console.error(`❌ 替换后文件为空,回滚: ${sourceFile}`);
          // 从备份恢复
          const backupPath = path.join(BACKUP_DIR, sourceFile);
          if (fs.existsSync(backupPath)) {
            fs.copyFileSync(backupPath, sourcePath);
          }
          totalFailed++;
          results.push({ key, sourceFile, status: 'replace_failed', reason: '替换后文件为空' });
          continue;
        }

        console.log(`✅ 替换: ${sourceFile} (${(translatedStat.size / 1024).toFixed(1)} KB ← ${translatedPath})`);
      } else {
        console.log(`🔍 可替换: ${sourceFile}(预览模式,来源: ${translatedPath})`);
      }

      totalReplaced++;
      results.push({ key, sourceFile, status: 'replaced', translatedFrom: translatedPath });
    } else {
      // 复制到语言目录模式
      const langDir = path.join(PROJECT_ROOT, `assets_${TARGET_LANG}`);
      const targetPath = path.join(langDir, sourceFile);
      const targetDir = path.dirname(targetPath);

      if (!DRY_RUN) {
        fs.mkdirSync(targetDir, { recursive: true });
        fs.copyFileSync(translatedPath, targetPath);
        console.log(`✅ 复制: ${sourceFile} → assets_${TARGET_LANG}/${sourceFile}`);
      } else {
        console.log(`🔍 可复制: ${sourceFile}(预览模式)`);
      }

      totalReplaced++;
      results.push({ key, sourceFile, status: 'copied', targetPath });
    }
  }

  // 3. 输出摘要
  console.log('');
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  console.log(`📊 图片替换完成`);
  console.log(`   成功: ${totalReplaced} 张`);
  console.log(`   失败: ${totalFailed} 张`);
  if (BACKUP && !DRY_RUN && MODE === 'replace') {
    console.log(`   备份: ${BACKUP_DIR}`);
  }
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');

  // 4. 保存替换报告
  if (!DRY_RUN) {
    const reportPath = path.join(I18N_DIR, 'image_replace_report.json');
    fs.writeFileSync(reportPath, JSON.stringify({
      timestamp: new Date().toISOString(),
      targetLanguage: TARGET_LANG,
      mode: MODE,
      totalReplaced,
      totalFailed,
      results
    }, null, 2), 'utf8');
    console.log(`📄 替换报告: ${reportPath}`);
  }

  if (totalFailed > 0) {
    process.exit(1);
  }
}

// ============================================================
// 工具函数
// ============================================================

function backupFile(fullPath, relativePath) {
  const backupPath = path.join(BACKUP_DIR, relativePath);
  const backupDir = path.dirname(backupPath);
  fs.mkdirSync(backupDir, { recursive: true });
  fs.copyFileSync(fullPath, backupPath);
}

function parseArgs(argv) {
  const args = {};
  for (let i = 0; i < argv.length; i++) {
    if (argv[i].startsWith('--')) {
      const key = argv[i].substring(2);
      const next = argv[i + 1];
      if (next && !next.startsWith('--')) {
        args[key] = next;
        i++;
      } else {
        args[key] = true;
      }
    }
  }
  return args;
}

// ============================================================
// 执行
// ============================================================

main().catch(err => {
  console.error('❌ 图片替换失败:', err.message);
  process.exit(1);
});