core_applier.js

'use strict';

/**
 * @fileoverview Translation replacement engine.
 * Applies extracted i18n keys to source files with backup and rollback support.
 * @module applier
 */

const fs = require('./fs-adapter');
const path = require('path');
const { withBackup } = require('./backup');
const {
  groupFindingsByFile,
  confirmFileModifications,
  loadFileForReplacement,
  applyReplacementsToContent,
  tryUpdateTsImports,
  logApplyResults,
  loadAndValidateReport,
} = require('./applier-utils');

function getCompanionTsFile(htmlFilePath) {
  return htmlFilePath.replace(/\.html$/, '.ts');
}

function logTsUpdate(updated, tsFile, opts) {
  if (updated && opts.verbose) {
    console.log(`  + TranslocoPipe added to ${path.relative(opts.srcDir, tsFile)}`);
  }
}

function updateCompanionTsFile(htmlFilePath, opts) {
  const tsFile = getCompanionTsFile(htmlFilePath);
  if (!fs.existsSync(tsFile)) {
    return;
  }
  try {
    logTsUpdate(tryUpdateTsImports(tsFile, opts), tsFile, opts);
  } catch {
    if (opts.verbose) {
      console.warn(`  Warning: Could not update ${tsFile}`);
    }
  }
}

function applyTsPostProcessing(content, opts) {
  return opts.adapter.updateImports(content);
}

function applyPostProcessing(ctx) {
  const { relativeFile, content, count, filePath, opts } = ctx;
  if (count > 0 && /\.ts$/.test(relativeFile)) {
    return applyTsPostProcessing(content, opts);
  }
  if (count > 0 && /\.html$/.test(relativeFile)) {
    updateCompanionTsFile(filePath, opts);
  }
  return content;
}

function buildFileResult(ctx) {
  const { filePath, content, originalContent, count } = ctx;
  return {
    filePath,
    content,
    originalContent,
    fileReplacements: count,
    hasChanges: content !== originalContent,
  };
}

function processFileReplacements(relativeFile, fileFindings, opts) {
  const { srcDir, verbose = false, adapter } = opts;
  const filePath = path.join(srcDir, relativeFile);
  const originalContent = loadFileForReplacement(filePath, relativeFile, verbose);
  if (!originalContent) {
    return null;
  }
  const { content: replaced, count } = applyReplacementsToContent(
    originalContent,
    fileFindings,
    adapter,
  );
  const content = applyPostProcessing({ relativeFile, content: replaced, count, filePath, opts });
  return buildFileResult({ filePath, content, originalContent, count });
}

function writeFileChanges(ctx) {
  const { relativeFile, result, modifiedFiles, dryRun, verbose, log } = ctx;
  if (!result.hasChanges) {
    return { files: 0, replacements: 0 };
  }
  if (dryRun) {
    log(`[DRY RUN] ${relativeFile}: ${result.fileReplacements} replacement(s)`);
  } else {
    fs.writeFileSync(result.filePath, result.content, 'utf-8');
    modifiedFiles.push({ file: relativeFile, count: result.fileReplacements });
    if (verbose) {
      log(`Modified: ${relativeFile} (${result.fileReplacements} replacement(s))`);
    }
  }
  return { files: 1, replacements: result.fileReplacements };
}

function processSingleFile(ctx) {
  const { relativeFile, fileFindings, modifiedFiles, opts } = ctx;
  const result = processFileReplacements(relativeFile, fileFindings, opts);
  if (!result) {
    return { files: 0, replacements: 0 };
  }
  const { dryRun = false, verbose = false, log = console.log } = opts;
  return writeFileChanges({ relativeFile, result, modifiedFiles, dryRun, verbose, log });
}

function processAllFiles(findingsByFile, opts) {
  const modifiedFiles = [];
  let totalFiles = 0;
  let totalReplacements = 0;
  for (const [relativeFile, fileFindings] of findingsByFile) {
    const counts = processSingleFile({ relativeFile, fileFindings, modifiedFiles, opts });
    totalFiles += counts.files;
    totalReplacements += counts.replacements;
  }
  return { totalFiles, totalReplacements, modifiedFiles };
}

function getFilesToBackup(findingsByFile, opts) {
  const { srcDir } = opts;
  const files = [];
  for (const [relativeFile] of findingsByFile) {
    const filePath = path.join(srcDir, relativeFile);
    if (fs.existsSync(filePath)) {
      files.push(filePath);
    }
  }
  return files;
}

function backupFilesForSession(filesToBackup, sessionHelpers) {
  for (const filePath of filesToBackup) {
    if (sessionHelpers?.backupFile) {
      sessionHelpers.backupFile(filePath);
    }
  }
}

function saveReportIfNeeded(sessionHelpers, reportPath) {
  if (reportPath && sessionHelpers?.saveReport) {
    sessionHelpers.saveReport(reportPath);
  }
}

function transitionSessionState(sessionHelpers) {
  if (sessionHelpers?.markReady) {
    sessionHelpers.markReady();
  }
  if (sessionHelpers?.beginModifications) {
    sessionHelpers.beginModifications();
  }
}

function prepareSession(sessionHelpers, reportPath) {
  saveReportIfNeeded(sessionHelpers, reportPath);
  transitionSessionState(sessionHelpers);
}

function createApplyExecutor(ctx) {
  const { filesToBackup, reportPath, findingsByFile, opts } = ctx;
  return sessionHelpers => {
    backupFilesForSession(filesToBackup, sessionHelpers);
    prepareSession(sessionHelpers, reportPath);
    const results = processAllFiles(findingsByFile, opts);
    logApplyResults(results, opts);
    return { success: true, ...results };
  };
}

function logApplyHeader(log) {
  log('Auto-Apply Translations');
  log('-'.repeat(50));
}

function buildApplyContext(findingsByFile, opts) {
  const cwd = process.cwd();
  const { reportDir } = opts;
  const filesToBackup = getFilesToBackup(findingsByFile, opts);
  const reportPath = reportDir ? path.join(reportDir, 'report.json') : null;
  return { cwd, filesToBackup, reportPath, findingsByFile, opts };
}

async function confirmAndPrepare(findings, opts) {
  const { log = console.log, interactive = false } = opts;
  logApplyHeader(log);
  const findingsByFile = groupFindingsByFile(findings);
  const shouldProceed = await confirmFileModifications(findingsByFile, interactive, log);
  return shouldProceed ? findingsByFile : null;
}

function executeApplyWithBackup(findingsByFile, opts) {
  const { log = console.log, backup = true, dryRun = false } = opts;
  const ctx = buildApplyContext(findingsByFile, opts);
  const executeApply = createApplyExecutor(ctx);
  return withBackup({
    cwd: ctx.cwd,
    command: 'apply',
    operation: executeApply,
    options: { log, backup, dryRun },
  });
}

/**
 * Applies translation replacements from findings array
 * @param {Finding[]} findings
 * @param {ApplyOptions} [opts]
 * @returns {Promise<ApplyResult>}
 */
async function applyFindings(findings, opts = {}) {
  const findingsByFile = await confirmAndPrepare(findings, opts);
  return findingsByFile ?
      executeApplyWithBackup(findingsByFile, opts)
    : { success: false, aborted: true };
}

/**
 * Applies translations from a JSON report file
 * @param {string} reportPath - Path to extracted-keys.json
 * @param {ApplyOptions} [opts]
 * @returns {Promise<ApplyResult>}
 * @example
 * await applyTranslations('./extracted-keys.json', { srcDir: './src', adapter });
 */
function applyTranslations(reportPath, opts = {}) {
  const { log = console.log } = opts;
  log('Apply Translations');
  log('='.repeat(50));
  log(`Report: ${reportPath}\n`);
  const report = loadAndValidateReport(reportPath);
  return applyFindings(report.findings, opts);
}

module.exports = { applyFindings, applyTranslations };