+
{{ 'test' | i18n
+
Incomplete template
+ `;
+
+ // Should not throw an error
+ const usages = parser.findI18nPipeUsage(template, "test.html");
+
+ // May or may not find usages depending on parser robustness
+ expect(Array.isArray(usages)).toBe(true);
+ });
+ });
+
+ describe("TemplateTransformer", () => {
+ let transformer: TemplateTransformer;
+
+ beforeEach(() => {
+ transformer = new TemplateTransformer();
+ });
+
+ it("should transform simple interpolation to i18n attribute", () => {
+ const template = `
{{ 'welcome' | i18n }}
`;
+
+ const result = transformer.transformTemplate(template, "test.html");
+
+ expect(result.success).toBe(true);
+ expect(result.changes).toHaveLength(1);
+ expect(result.changes[0].replacement).toContain('i18n="@@welcome"');
+ expect(result.changes[0].replacement).toContain("
{
+ const template = ``;
+
+ const result = transformer.transformTemplate(template, "test.html");
+
+ expect(result.success).toBe(true);
+ expect(result.changes).toHaveLength(1);
+ expect(result.changes[0].replacement).toContain('i18n-title="@@click-me"');
+ });
+
+ it("should handle multiple i18n pipe usages", () => {
+ const template = `
+
+
{{ 'title' | i18n }}
+
{{ 'description' | i18n }}
+
+
+ `;
+
+ const result = transformer.transformTemplate(template, "test.html");
+
+ expect(result.success).toBe(true);
+ expect(result.changes.length).toBeGreaterThan(0);
+ });
+
+ it("should generate proper i18n IDs from keys", () => {
+ const template = `{{ 'camelCaseKey' | i18n }}`;
+
+ const result = transformer.transformTemplate(template, "test.html");
+
+ expect(result.success).toBe(true);
+ expect(result.changes[0].replacement).toContain("@@camel-case-key");
+ });
+
+ it("should handle templates without i18n pipes", () => {
+ const template = `
+
+
Static Title
+
{{ someVariable }}
+
+ `;
+
+ const result = transformer.transformTemplate(template, "test.html");
+
+ expect(result.success).toBe(true);
+ expect(result.changes).toHaveLength(0);
+ });
+
+ it("should validate transformations", () => {
+ const original = `{{ 'test' | i18n }}
`;
+ const validTransformed = `test
`;
+ const invalidTransformed = `test
`;
+
+ expect(transformer.validateTransformation(original, validTransformed)).toBe(true);
+ expect(transformer.validateTransformation(original, invalidTransformed)).toBe(false);
+ });
+ });
+
+ describe("Integration Tests", () => {
+ it("should handle complex template transformation", () => {
+ const transformer = new TemplateTransformer();
+ const template = `
+
+
+
+ {{ 'welcomeMessage' | i18n }}
+
+
+
+ `;
+
+ const result = transformer.transformTemplate(template, "complex-test.html");
+
+ expect(result.success).toBe(true);
+ expect(result.changes.length).toBeGreaterThan(0);
+
+ // Verify that all i18n pipes were found and transformed
+ const originalPipeCount = (template.match(/\|\s*i18n/g) || []).length;
+ expect(result.changes.length).toBe(originalPipeCount);
+ });
+
+ it("should preserve template structure during transformation", () => {
+ const transformer = new TemplateTransformer();
+ const template = `
+
+
Before: {{ 'message' | i18n }}
+
Static content
+
After: {{ 'anotherMessage' | i18n }}
+
+ `;
+
+ const result = transformer.transformTemplate(template, "structure-test.html");
+
+ expect(result.success).toBe(true);
+
+ // Apply transformations to see the result
+ let transformedContent = template;
+ for (const change of result.changes.reverse()) {
+ if (change.original && change.replacement) {
+ transformedContent = transformedContent.replace(change.original, change.replacement);
+ }
+ }
+
+ // Verify structure is preserved
+ expect(transformedContent).toContain("");
+ expect(transformedContent).toContain("
");
+ expect(transformedContent).toContain("Static content");
+ expect(transformedContent).toContain("Before:");
+ expect(transformedContent).toContain("After:");
+ });
+ });
+});
diff --git a/scripts/migration/i18n/tests/typescript-migrator.test.ts b/scripts/migration/i18n/tests/typescript-migrator.test.ts
new file mode 100644
index 00000000000..c7b99b45457
--- /dev/null
+++ b/scripts/migration/i18n/tests/typescript-migrator.test.ts
@@ -0,0 +1,194 @@
+import { Project, SourceFile } from "ts-morph";
+import { ASTTransformer } from "../typescript/ast-transformer";
+import { MigrationConfig } from "../shared/types";
+
+describe("TypeScript Migration Tools", () => {
+ let project: Project;
+
+ beforeEach(() => {
+ project = new Project({
+ useInMemoryFileSystem: true,
+ });
+ });
+
+ describe("ASTTransformer", () => {
+ let transformer: ASTTransformer;
+ let sourceFile: SourceFile;
+
+ beforeEach(() => {
+ transformer = new ASTTransformer();
+ });
+
+ it("should find I18nService.t() calls", () => {
+ const code = `
+ import { I18nService } from '@bitwarden/common/platform/services/i18n.service';
+
+ class TestComponent {
+ constructor(private i18nService: I18nService) {}
+
+ test() {
+ const message = this.i18nService.t('loginWithDevice');
+ const countMessage = this.i18nService.t('itemsCount', count.toString());
+ }
+ }
+ `;
+
+ sourceFile = project.createSourceFile("test.ts", code);
+ const usages = transformer.findI18nServiceCalls(sourceFile);
+
+ expect(usages).toHaveLength(2);
+ expect(usages[0].key).toBe("loginWithDevice");
+ expect(usages[0].method).toBe("t");
+ expect(usages[1].key).toBe("itemsCount");
+ expect(usages[1].parameters).toEqual(["count.toString()"]);
+ });
+
+ it("should transform I18nService.t() to $localize but keep import due to constructor usage", () => {
+ const code = `
+ import { I18nService } from '@bitwarden/common/platform/services/i18n.service';
+
+ class TestComponent {
+ constructor(private i18nService: I18nService) {}
+
+ test() {
+ const message = this.i18nService.t('loginWithDevice');
+ }
+ }
+ `;
+
+ sourceFile = project.createSourceFile("test.ts", code);
+ const result = transformer.transformI18nServiceCalls(sourceFile);
+
+ expect(result.success).toBe(true);
+ expect(result.changes).toHaveLength(1); // Only transformation, import kept due to constructor usage
+ expect(result.changes[0].replacement).toBe("$localize`loginWithDevice`");
+ expect(sourceFile.getFullText()).toContain("$localize`loginWithDevice`");
+ expect(sourceFile.getFullText()).toContain("I18nService"); // Import should still be there
+ });
+
+ it("should handle parameters in I18nService.t() calls", () => {
+ const code = `
+ class TestComponent {
+ test() {
+ const message = this.i18nService.t('itemsCount', count.toString());
+ }
+ }
+ `;
+
+ sourceFile = project.createSourceFile("test.ts", code);
+ const result = transformer.transformI18nServiceCalls(sourceFile);
+
+ expect(result.success).toBe(true);
+ expect(result.changes[0].replacement).toBe(
+ "$localize`itemsCount\${count.toString()}:param0:`",
+ );
+ });
+
+ it("should handle files without I18nService usage", () => {
+ const code = `
+ import { Component } from '@angular/core';
+
+ @Component({})
+ class TestComponent {
+ test() {
+ console.log('no i18n here');
+ }
+ }
+ `;
+
+ sourceFile = project.createSourceFile("test.ts", code);
+ const result = transformer.transformI18nServiceCalls(sourceFile);
+
+ expect(result.success).toBe(true);
+ expect(result.changes).toHaveLength(0);
+ });
+
+ it("should remove I18nService import when no longer used", () => {
+ const code = `
+ import { I18nService } from '@bitwarden/common/platform/services/i18n.service';
+ import { Component } from '@angular/core';
+
+ @Component({})
+ class TestComponent {
+ test() {
+ const message = this.i18nService.t('loginWithDevice');
+ }
+ }
+ `;
+
+ sourceFile = project.createSourceFile("test.ts", code);
+ const result = transformer.transformI18nServiceCalls(sourceFile);
+
+ expect(result.success).toBe(true);
+ expect(result.changes).toHaveLength(2); // One for transformation, one for import removal
+ expect(sourceFile.getFullText()).not.toContain("I18nService");
+ });
+ });
+
+ describe("Integration Tests", () => {
+ it("should handle complex transformation scenarios", () => {
+ const transformer = new ASTTransformer();
+ const code = `
+ import { I18nService } from '@bitwarden/common/platform/services/i18n.service';
+ import { Component } from '@angular/core';
+
+ @Component({})
+ class TestComponent {
+ constructor(private i18nService: I18nService) {}
+
+ getMessage() {
+ return this.i18nService.t('simpleMessage');
+ }
+
+ getParameterizedMessage(count: number) {
+ return this.i18nService.t('itemCount', count.toString());
+ }
+
+ getMultipleMessages() {
+ const msg1 = this.i18nService.t('message1');
+ const msg2 = this.i18nService.t('message2', 'param');
+ return [msg1, msg2];
+ }
+ }
+ `;
+
+ const sourceFile = project.createSourceFile("complex-test.ts", code);
+ const result = transformer.transformI18nServiceCalls(sourceFile);
+
+ expect(result.success).toBe(true);
+ expect(result.changes.length).toBe(4); // 4 transformations, no import removal due to constructor
+
+ const transformedCode = sourceFile.getFullText();
+ expect(transformedCode).toContain("$localize`simpleMessage`");
+ expect(transformedCode).toContain("$localize`itemCount\${count.toString()}:param0:`");
+ expect(transformedCode).toContain("$localize`message1`");
+ expect(transformedCode).toContain("$localize`message2\${'param'}:param0:`");
+
+ // Should keep the I18nService import due to constructor usage
+ expect(transformedCode).toContain("I18nService");
+ });
+
+ it("should remove import when only method calls are used (no constructor)", () => {
+ const transformer = new ASTTransformer();
+ const code = `
+ import { I18nService } from '@bitwarden/common/platform/services/i18n.service';
+
+ class TestComponent {
+ test() {
+ const message = this.i18nService.t('testMessage');
+ }
+ }
+ `;
+
+ const sourceFile = project.createSourceFile("no-constructor-test.ts", code);
+ const result = transformer.transformI18nServiceCalls(sourceFile);
+
+ expect(result.success).toBe(true);
+ expect(result.changes.length).toBe(2); // 1 transformation + 1 import removal
+
+ const transformedCode = sourceFile.getFullText();
+ expect(transformedCode).toContain("$localize`testMessage`");
+ expect(transformedCode).not.toContain("I18nService");
+ });
+ });
+});
diff --git a/scripts/migration/i18n/tsconfig.json b/scripts/migration/i18n/tsconfig.json
new file mode 100644
index 00000000000..1081656d04a
--- /dev/null
+++ b/scripts/migration/i18n/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "es2020",
+ "lib": ["es2020"],
+ "declaration": false,
+ "strict": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "moduleResolution": "node",
+ "allowSyntheticDefaultImports": true,
+ "experimentalDecorators": true,
+ "importHelpers": true,
+ "types": ["node", "jest"]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/scripts/migration/i18n/tsconfig.spec.json b/scripts/migration/i18n/tsconfig.spec.json
new file mode 100644
index 00000000000..1081656d04a
--- /dev/null
+++ b/scripts/migration/i18n/tsconfig.spec.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "es2020",
+ "lib": ["es2020"],
+ "declaration": false,
+ "strict": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "moduleResolution": "node",
+ "allowSyntheticDefaultImports": true,
+ "experimentalDecorators": true,
+ "importHelpers": true,
+ "types": ["node", "jest"]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/scripts/migration/i18n/typescript/ast-transformer.ts b/scripts/migration/i18n/typescript/ast-transformer.ts
new file mode 100644
index 00000000000..d62f5466c3a
--- /dev/null
+++ b/scripts/migration/i18n/typescript/ast-transformer.ts
@@ -0,0 +1,197 @@
+import { SourceFile, Node } from "ts-morph";
+
+import { TransformationResult, TransformationChange, I18nUsage } from "../shared/types";
+
+/**
+ * AST transformation utilities for TypeScript code migration
+ */
+export class ASTTransformer {
+ /**
+ * Find all I18nService.t() method calls in a source file
+ */
+ findI18nServiceCalls(sourceFile: SourceFile): I18nUsage[] {
+ const usages: I18nUsage[] = [];
+
+ sourceFile.forEachDescendant((node) => {
+ if (Node.isCallExpression(node)) {
+ const expression = node.getExpression();
+
+ if (Node.isPropertyAccessExpression(expression)) {
+ const object = expression.getExpression();
+ const property = expression.getName();
+
+ // Check if this is a call to i18nService.t() or this.i18n.t()
+ if (property === "t" && this.isI18nServiceAccess(object)) {
+ const args = node.getArguments();
+ if (args.length > 0) {
+ const keyArg = args[0];
+ const key = this.extractStringLiteral(keyArg);
+
+ if (key) {
+ const parameters = args.slice(1).map((arg) => arg.getText());
+ const { line, column } = sourceFile.getLineAndColumnAtPos(node.getStart());
+
+ usages.push({
+ filePath: sourceFile.getFilePath(),
+ line,
+ column,
+ method: "t",
+ key,
+ parameters: parameters.length > 0 ? parameters : undefined,
+ });
+ }
+ }
+ }
+ }
+ }
+ });
+
+ return usages;
+ }
+
+ /**
+ * Transform I18nService.t() calls to $localize calls
+ */
+ transformI18nServiceCalls(sourceFile: SourceFile): TransformationResult {
+ const changes: TransformationChange[] = [];
+ const errors: string[] = [];
+
+ try {
+ // Find and replace I18nService calls
+ sourceFile.forEachDescendant((node) => {
+ if (Node.isCallExpression(node)) {
+ const expression = node.getExpression();
+
+ if (Node.isPropertyAccessExpression(expression)) {
+ const object = expression.getExpression();
+ const property = expression.getName();
+
+ if (property === "t" && this.isI18nServiceAccess(object)) {
+ const args = node.getArguments();
+ if (args.length > 0) {
+ const keyArg = args[0];
+ const key = this.extractStringLiteral(keyArg);
+
+ if (key) {
+ const { line, column } = sourceFile.getLineAndColumnAtPos(node.getStart());
+ const original = node.getText();
+
+ // Generate $localize replacement
+ const replacement = this.generateLocalizeCall(key, args.slice(1));
+
+ // Replace the node
+ node.replaceWithText(replacement);
+
+ changes.push({
+ type: "replace",
+ location: { line, column },
+ original,
+ replacement,
+ description: `Replaced i18nService.t('${key}') with $localize`,
+ });
+ }
+ }
+ }
+ }
+ }
+ });
+
+ // Remove I18nService imports if no longer used
+ this.removeUnusedI18nImports(sourceFile, changes);
+
+ return {
+ success: true,
+ filePath: sourceFile.getFilePath(),
+ changes,
+ errors,
+ };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ errors.push(`Error transforming file: ${errorMessage}`);
+ return {
+ success: false,
+ filePath: sourceFile.getFilePath(),
+ changes,
+ errors,
+ };
+ }
+ }
+
+ /**
+ * Check if a node represents access to I18nService
+ */
+ private isI18nServiceAccess(node: Node): boolean {
+ const text = node.getText();
+ return text.includes("i18nService") || text.includes("i18n") || text.includes("this.i18n");
+ }
+
+ /**
+ * Extract string literal value from a node
+ */
+ private extractStringLiteral(node: Node): string | null {
+ if (Node.isStringLiteral(node)) {
+ return node.getLiteralValue();
+ }
+ if (Node.isNoSubstitutionTemplateLiteral(node)) {
+ return node.getLiteralValue();
+ }
+ return null;
+ }
+
+ /**
+ * Generate $localize call with parameters
+ */
+ private generateLocalizeCall(key: string, paramArgs: Node[]): string {
+ if (paramArgs.length === 0) {
+ return `$localize\`${key}\``;
+ }
+
+ // For now, handle simple parameter substitution
+ // This will need to be enhanced for complex cases
+ const params = paramArgs.map((arg, index) => `\${${arg.getText()}}:param${index}:`);
+ return `$localize\`${key}${params.join("")}\``;
+ }
+
+ /**
+ * Remove unused I18nService imports
+ */
+ private removeUnusedI18nImports(sourceFile: SourceFile, changes: TransformationChange[]): void {
+ const imports = sourceFile.getImportDeclarations();
+
+ imports.forEach((importDecl) => {
+ const moduleSpecifier = importDecl.getModuleSpecifierValue();
+
+ if (moduleSpecifier.includes("i18n.service")) {
+ // Check if I18nService is still used in the file
+ const text = sourceFile.getFullText();
+
+ // Look for actual I18nService usage (constructor parameters, type annotations, etc.)
+ // but exclude the .t() method calls since we've transformed those
+ const hasI18nServiceType =
+ text.includes(": I18nService") ||
+ text.includes("") ||
+ text.includes("I18nService>") ||
+ text.includes("I18nService,") ||
+ text.includes("I18nService)");
+
+ // Check for remaining .t() calls that weren't transformed
+ const hasRemainingTCalls = text.match(/\bi18nService\.t\s*\(/);
+
+ // Only remove if there are no type references and no remaining method calls
+ if (!hasI18nServiceType && !hasRemainingTCalls) {
+ const { line, column } = sourceFile.getLineAndColumnAtPos(importDecl.getStart());
+ const original = importDecl.getText();
+
+ importDecl.remove();
+
+ changes.push({
+ type: "remove",
+ location: { line, column },
+ original,
+ description: "Removed unused I18nService import",
+ });
+ }
+ }
+ });
+ }
+}
diff --git a/scripts/migration/i18n/typescript/project-parser.ts b/scripts/migration/i18n/typescript/project-parser.ts
new file mode 100644
index 00000000000..59854bbd69d
--- /dev/null
+++ b/scripts/migration/i18n/typescript/project-parser.ts
@@ -0,0 +1,79 @@
+import { Project, SourceFile } from "ts-morph";
+
+import { MigrationConfig } from "../shared/types";
+
+/**
+ * Utility class for parsing TypeScript projects using ts-morph
+ */
+export class ProjectParser {
+ private project: Project;
+
+ constructor(private config: MigrationConfig) {
+ this.project = new Project({
+ tsConfigFilePath: config.tsConfigPath,
+ skipAddingFilesFromTsConfig: false,
+ });
+ }
+
+ /**
+ * Get all source files in the project
+ */
+ getSourceFiles(): SourceFile[] {
+ return this.project.getSourceFiles();
+ }
+
+ /**
+ * Get a specific source file by path
+ */
+ getSourceFile(filePath: string): SourceFile | undefined {
+ return this.project.getSourceFile(filePath);
+ }
+
+ /**
+ * Add a source file to the project
+ */
+ addSourceFile(filePath: string): SourceFile {
+ return this.project.addSourceFileAtPath(filePath);
+ }
+
+ /**
+ * Save all changes to disk
+ */
+ async saveChanges(): Promise {
+ if (!this.config.dryRun) {
+ await this.project.save();
+ }
+ }
+
+ /**
+ * Get the underlying ts-morph Project instance
+ */
+ getProject(): Project {
+ return this.project;
+ }
+
+ /**
+ * Find files that import I18nService
+ */
+ findI18nServiceImports(): SourceFile[] {
+ return this.project.getSourceFiles().filter((sourceFile) => {
+ return sourceFile.getImportDeclarations().some((importDecl) => {
+ const moduleSpecifier = importDecl.getModuleSpecifierValue();
+ return (
+ moduleSpecifier.includes("i18n.service") ||
+ moduleSpecifier.includes("@bitwarden/common/platform/services/i18n.service")
+ );
+ });
+ });
+ }
+
+ /**
+ * Find files that use the i18n pipe in template strings
+ */
+ findI18nPipeUsage(): SourceFile[] {
+ return this.project.getSourceFiles().filter((sourceFile) => {
+ const text = sourceFile.getFullText();
+ return text.includes("| i18n") || text.includes("|i18n");
+ });
+ }
+}
diff --git a/scripts/migration/i18n/typescript/typescript-migrator.ts b/scripts/migration/i18n/typescript/typescript-migrator.ts
new file mode 100644
index 00000000000..c3516d4c384
--- /dev/null
+++ b/scripts/migration/i18n/typescript/typescript-migrator.ts
@@ -0,0 +1,174 @@
+/* eslint-disable no-console */
+import { MigrationConfig, TransformationResult, I18nUsage } from "../shared/types";
+
+import { ASTTransformer } from "./ast-transformer";
+import { ProjectParser } from "./project-parser";
+
+/**
+ * Main class for TypeScript code migration from I18nService to $localize
+ */
+export class TypeScriptMigrator {
+ private parser: ProjectParser;
+ private transformer: ASTTransformer;
+
+ constructor(private config: MigrationConfig) {
+ this.parser = new ProjectParser(config);
+ this.transformer = new ASTTransformer();
+ }
+
+ /**
+ * Analyze current I18nService usage across the project
+ */
+ analyzeUsage(): I18nUsage[] {
+ const sourceFiles = this.parser.findI18nServiceImports();
+ const allUsages: I18nUsage[] = [];
+
+ sourceFiles.forEach((sourceFile) => {
+ const usages = this.transformer.findI18nServiceCalls(sourceFile);
+ allUsages.push(...usages);
+ });
+
+ return allUsages;
+ }
+
+ /**
+ * Generate analysis report of current usage patterns
+ */
+ generateAnalysisReport(): string {
+ const usages = this.analyzeUsage();
+ const fileCount = new Set(usages.map((u) => u.filePath)).size;
+ const keyCount = new Set(usages.map((u) => u.key)).size;
+
+ let report = `# I18nService Usage Analysis Report\n\n`;
+ report += `## Summary\n`;
+ report += `- Total usage count: ${usages.length}\n`;
+ report += `- Files affected: ${fileCount}\n`;
+ report += `- Unique translation keys: ${keyCount}\n\n`;
+
+ report += `## Usage by File\n`;
+ const usagesByFile = usages.reduce(
+ (acc, usage) => {
+ if (!acc[usage.filePath]) {
+ acc[usage.filePath] = [];
+ }
+ acc[usage.filePath].push(usage);
+ return acc;
+ },
+ {} as Record,
+ );
+
+ Object.entries(usagesByFile).forEach(([filePath, fileUsages]) => {
+ report += `\n### ${filePath}\n`;
+ fileUsages.forEach((usage) => {
+ report += `- Line ${usage.line}: \`${usage.key}\``;
+ if (usage.parameters) {
+ report += ` (with parameters: ${usage.parameters.join(", ")})`;
+ }
+ report += `\n`;
+ });
+ });
+
+ report += `\n## Most Common Keys\n`;
+ const keyCounts = usages.reduce(
+ (acc, usage) => {
+ acc[usage.key] = (acc[usage.key] || 0) + 1;
+ return acc;
+ },
+ {} as Record,
+ );
+
+ Object.entries(keyCounts)
+ .sort(([, a], [, b]) => b - a)
+ .slice(0, 10)
+ .forEach(([key, count]) => {
+ report += `- \`${key}\`: ${count} usage(s)\n`;
+ });
+
+ return report;
+ }
+
+ /**
+ * Migrate all TypeScript files in the project
+ */
+ async migrateAll(): Promise {
+ const sourceFiles = this.parser.findI18nServiceImports();
+ const results: TransformationResult[] = [];
+
+ if (this.config.verbose) {
+ console.log(`Found ${sourceFiles.length} files with I18nService imports`);
+ }
+
+ for (const sourceFile of sourceFiles) {
+ if (this.config.verbose) {
+ console.log(`Processing: ${sourceFile.getFilePath()}`);
+ }
+
+ const result = this.transformer.transformI18nServiceCalls(sourceFile);
+ results.push(result);
+
+ if (!result.success) {
+ console.error(`Failed to process ${result.filePath}:`, result.errors);
+ }
+ }
+
+ // Save changes if not in dry run mode
+ if (!this.config.dryRun) {
+ await this.parser.saveChanges();
+ }
+
+ return results;
+ }
+
+ /**
+ * Migrate a specific file
+ */
+ async migrateFile(filePath: string): Promise {
+ const sourceFile = this.parser.getSourceFile(filePath);
+
+ if (!sourceFile) {
+ return {
+ success: false,
+ filePath,
+ changes: [],
+ errors: [`File not found: ${filePath}`],
+ };
+ }
+
+ const result = this.transformer.transformI18nServiceCalls(sourceFile);
+
+ if (!this.config.dryRun && result.success) {
+ await this.parser.saveChanges();
+ }
+
+ return result;
+ }
+
+ /**
+ * Generate migration statistics
+ */
+ generateMigrationStats(results: TransformationResult[]): string {
+ const successful = results.filter((r) => r.success).length;
+ const failed = results.filter((r) => !r.success).length;
+ const totalChanges = results.reduce((sum, r) => sum + r.changes.length, 0);
+
+ let stats = `# Migration Statistics\n\n`;
+ stats += `- Files processed: ${results.length}\n`;
+ stats += `- Successful: ${successful}\n`;
+ stats += `- Failed: ${failed}\n`;
+ stats += `- Total changes: ${totalChanges}\n\n`;
+
+ if (failed > 0) {
+ stats += `## Failed Files\n`;
+ results
+ .filter((r) => !r.success)
+ .forEach((result) => {
+ stats += `- ${result.filePath}\n`;
+ result.errors.forEach((error) => {
+ stats += ` - ${error}\n`;
+ });
+ });
+ }
+
+ return stats;
+ }
+}