1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 04:03:29 +00:00

Support mapping

This commit is contained in:
Hinton
2025-07-28 15:57:28 +02:00
parent 77a89c2e31
commit 2de08377ee
25 changed files with 2594 additions and 9 deletions

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import * as fs from "fs";
import * as path from "path";
import * as chalk from "chalk";
import { Command } from "commander";
import { TranslationCombiner } from "./translation-combiner";
import { TranslationLookup } from "./translation-lookup";
const program = new Command();
program
.name("translation-combiner")
.description("CLI tool for combining translation files from all applications")
.version("1.0.0");
program
.command("combine")
.description("Combine all application translation files into a single lookup file")
.option(
"-o, --output <path>",
"Output file for combined translations",
"./combined-translations.json",
)
.option("-r, --report <path>", "Output file for combination report")
.option("-v, --verbose", "Enable verbose logging")
.action(async (options) => {
try {
console.log(chalk.blue("🔄 Combining translation files..."));
const combiner = new TranslationCombiner();
const result = combiner.combineTranslations();
// Save combined translations
combiner.saveCombinedTranslations(result, options.output);
console.log(chalk.green(`✅ Combined translations saved to: ${options.output}`));
// Generate and save report
const report = combiner.generateCombinationReport(result);
if (options.report) {
fs.writeFileSync(options.report, report);
console.log(chalk.green(`📊 Combination report saved to: ${options.report}`));
} else if (options.verbose) {
console.log(report);
}
// Display summary
console.log(chalk.blue("\n📈 Summary:"));
console.log(` Total unique keys: ${result.totalKeys}`);
console.log(` Source applications: ${result.sources.length}`);
console.log(` Conflicts found: ${result.conflicts.length}`);
if (result.conflicts.length > 0) {
console.log(
chalk.yellow(`\n⚠ Found ${result.conflicts.length} conflicts between applications`),
);
if (!options.verbose) {
console.log(chalk.gray("Use --verbose or --report to see conflict details"));
}
}
} catch (error) {
console.error(chalk.red("❌ Failed to combine translations:"), error);
process.exit(1);
}
});
program
.command("validate")
.description("Validate translation keys against template usage")
.option(
"-c, --combined <path>",
"Path to combined translations file",
"./combined-translations.json",
)
.option("-p, --pattern <pattern>", "Glob pattern for template files", "**/*.html")
.option("-o, --output <path>", "Output file for validation report")
.option("-v, --verbose", "Enable verbose logging")
.action(async (options) => {
try {
console.log(chalk.blue("🔍 Validating translation keys..."));
if (!fs.existsSync(options.combined)) {
console.error(chalk.red(`❌ Combined translations file not found: ${options.combined}`));
console.log(
chalk.gray("Run 'combine' command first to generate the combined translations file"),
);
process.exit(1);
}
const lookup = new TranslationLookup();
await lookup.loadTranslations(options.combined);
// Find template files (simplified - in real implementation would use proper glob)
const templateFiles = findTemplateFiles(options.pattern);
if (templateFiles.length === 0) {
console.log(chalk.yellow("⚠️ No template files found matching pattern"));
return;
}
console.log(chalk.gray(`Found ${templateFiles.length} template files`));
// This would require the enhanced transformer
// For now, just show stats
const stats = lookup.getStats();
console.log(chalk.blue("\n📊 Translation Statistics:"));
console.log(` Total translations loaded: ${stats.totalKeys}`);
console.log(` Lookup service ready: ${stats.isLoaded ? "Yes" : "No"}`);
} catch (error) {
console.error(chalk.red("❌ Validation failed:"), error);
process.exit(1);
}
});
program
.command("search")
.description("Search for translation keys or values")
.argument("<query>", "Search query")
.option(
"-c, --combined <path>",
"Path to combined translations file",
"./combined-translations.json",
)
.option("-l, --limit <number>", "Maximum number of results", "10")
.option("-v, --verbose", "Enable verbose logging")
.action(async (query, options) => {
try {
if (!fs.existsSync(options.combined)) {
console.error(chalk.red(`❌ Combined translations file not found: ${options.combined}`));
console.log(
chalk.gray("Run 'combine' command first to generate the combined translations file"),
);
process.exit(1);
}
const lookup = new TranslationLookup();
await lookup.loadTranslations(options.combined);
console.log(chalk.blue(`🔍 Searching for: "${query}"`));
const results = lookup.search(query);
const limit = parseInt(options.limit);
const displayResults = results.slice(0, limit);
if (displayResults.length === 0) {
console.log(chalk.yellow("No results found"));
return;
}
console.log(
chalk.green(
`\n📋 Found ${results.length} results (showing top ${displayResults.length}):\n`,
),
);
displayResults.forEach((result, index) => {
console.log(`${index + 1}. ${chalk.cyan(result.key)} (relevance: ${result.relevance})`);
console.log(` "${result.message}"`);
console.log();
});
if (results.length > limit) {
console.log(chalk.gray(`... and ${results.length - limit} more results`));
}
} catch (error) {
console.error(chalk.red("❌ Search failed:"), error);
process.exit(1);
}
});
program
.command("stats")
.description("Show statistics about combined translations")
.option(
"-c, --combined <path>",
"Path to combined translations file",
"./combined-translations.json",
)
.action(async (options) => {
try {
if (!fs.existsSync(options.combined)) {
console.error(chalk.red(`❌ Combined translations file not found: ${options.combined}`));
console.log(
chalk.gray("Run 'combine' command first to generate the combined translations file"),
);
process.exit(1);
}
const content = fs.readFileSync(options.combined, "utf-8");
const data = JSON.parse(content);
console.log(chalk.blue("📊 Translation Statistics\n"));
if (data.metadata) {
console.log(`Generated: ${new Date(data.metadata.generatedAt).toLocaleString()}`);
console.log(`Total keys: ${data.metadata.totalKeys}`);
console.log(`Conflicts: ${data.metadata.conflictCount}`);
console.log(`Sources: ${data.metadata.sources.length}\n`);
console.log(chalk.blue("📱 Source Applications:"));
data.metadata.sources.forEach((source: any) => {
console.log(` ${source.app}: ${source.keyCount} keys`);
});
} else {
const keys = Object.keys(data.translations || data);
console.log(`Total keys: ${keys.length}`);
}
} catch (error) {
console.error(chalk.red("❌ Failed to show stats:"), error);
process.exit(1);
}
});
// Simple file finder (would be replaced with proper glob in real implementation)
function findTemplateFiles(pattern: string): string[] {
// This is a simplified implementation
// In practice, you'd use the same logic as in the template CLI
return [];
}
// Handle uncaught errors
process.on("uncaughtException", (error) => {
console.error(chalk.red("❌ Uncaught Exception:"), error);
process.exit(1);
});
process.on("unhandledRejection", (reason, promise) => {
console.error(chalk.red("❌ Unhandled Rejection at:"), promise, "reason:", reason);
process.exit(1);
});
program.parse();

View File

@@ -0,0 +1,256 @@
import * as fs from "fs";
import * as path from "path";
import { TranslationCombiner } from "./translation-combiner";
describe("TranslationCombiner", () => {
let combiner: TranslationCombiner;
let testDir: string;
beforeEach(() => {
testDir = path.join(__dirname, "test-translations");
combiner = new TranslationCombiner(testDir);
// Create test directory structure
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true });
}
fs.mkdirSync(testDir, { recursive: true });
// Create mock translation files
createMockTranslationFiles();
});
afterEach(() => {
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true });
}
});
function createMockTranslationFiles() {
// Browser translations
const browserDir = path.join(testDir, "apps/browser/src/_locales/en");
fs.mkdirSync(browserDir, { recursive: true });
fs.writeFileSync(
path.join(browserDir, "messages.json"),
JSON.stringify(
{
appName: { message: "Bitwarden" },
login: { message: "Log in" },
password: { message: "Password" },
browserSpecific: { message: "Browser Extension" },
},
null,
2,
),
);
// Desktop translations
const desktopDir = path.join(testDir, "apps/desktop/src/locales/en");
fs.mkdirSync(desktopDir, { recursive: true });
fs.writeFileSync(
path.join(desktopDir, "messages.json"),
JSON.stringify(
{
appName: { message: "Bitwarden" }, // Same as browser
login: { message: "Sign in" }, // Different from browser (conflict)
vault: { message: "Vault" },
desktopSpecific: { message: "Desktop Application" },
},
null,
2,
),
);
// Web translations
const webDir = path.join(testDir, "apps/web/src/locales/en");
fs.mkdirSync(webDir, { recursive: true });
fs.writeFileSync(
path.join(webDir, "messages.json"),
JSON.stringify(
{
dashboard: { message: "Dashboard" },
settings: { message: "Settings" },
webSpecific: { message: "Web Vault" },
},
null,
2,
),
);
// CLI translations
const cliDir = path.join(testDir, "apps/cli/src/locales/en");
fs.mkdirSync(cliDir, { recursive: true });
fs.writeFileSync(
path.join(cliDir, "messages.json"),
JSON.stringify(
{
version: { message: "Version" },
help: { message: "Help" },
cliSpecific: { message: "Command Line Interface" },
},
null,
2,
),
);
}
describe("combineTranslations", () => {
it("should combine translations from all applications", () => {
const result = combiner.combineTranslations();
expect(result.totalKeys).toBeGreaterThan(0);
expect(result.sources).toHaveLength(4); // browser, desktop, web, cli
expect(result.translations).toHaveProperty("appName");
expect(result.translations).toHaveProperty("browserSpecific");
expect(result.translations).toHaveProperty("desktopSpecific");
expect(result.translations).toHaveProperty("webSpecific");
expect(result.translations).toHaveProperty("cliSpecific");
});
it("should detect conflicts between applications", () => {
const result = combiner.combineTranslations();
expect(result.conflicts.length).toBeGreaterThan(0);
// Should detect the login conflict between browser and desktop
const loginConflict = result.conflicts.find((c) => c.key === "login");
expect(loginConflict).toBeDefined();
expect(loginConflict?.values).toContain("Log in");
expect(loginConflict?.values).toContain("Sign in");
});
it("should preserve first occurrence for conflicting keys", () => {
const result = combiner.combineTranslations();
// Browser is processed first, so its value should be preserved
expect(result.translations.appName.message).toBe("Bitwarden");
expect(result.translations.login.message).toBe("Log in"); // Browser version
});
it("should track source information", () => {
const result = combiner.combineTranslations();
const browserSource = result.sources.find((s) => s.app === "browser");
const desktopSource = result.sources.find((s) => s.app === "desktop");
const webSource = result.sources.find((s) => s.app === "web");
const cliSource = result.sources.find((s) => s.app === "cli");
expect(browserSource).toBeDefined();
expect(desktopSource).toBeDefined();
expect(webSource).toBeDefined();
expect(cliSource).toBeDefined();
expect(browserSource?.keyCount).toBe(4);
expect(desktopSource?.keyCount).toBe(4);
expect(webSource?.keyCount).toBe(3);
expect(cliSource?.keyCount).toBe(3);
});
});
describe("saveCombinedTranslations", () => {
it("should save combined translations with metadata", () => {
const result = combiner.combineTranslations();
const outputPath = path.join(testDir, "combined.json");
combiner.saveCombinedTranslations(result, outputPath);
expect(fs.existsSync(outputPath)).toBe(true);
const saved = JSON.parse(fs.readFileSync(outputPath, "utf-8"));
expect(saved).toHaveProperty("metadata");
expect(saved).toHaveProperty("translations");
expect(saved.metadata).toHaveProperty("generatedAt");
expect(saved.metadata).toHaveProperty("sources");
expect(saved.metadata).toHaveProperty("totalKeys");
expect(saved.metadata).toHaveProperty("conflictCount");
});
});
describe("generateCombinationReport", () => {
it("should generate a comprehensive report", () => {
const result = combiner.combineTranslations();
const report = combiner.generateCombinationReport(result);
expect(report).toContain("Translation Combination Report");
expect(report).toContain("Summary");
expect(report).toContain("Sources");
expect(report).toContain("Key Distribution");
if (result.conflicts.length > 0) {
expect(report).toContain("Conflicts");
}
});
});
describe("utility methods", () => {
it("should get translation message for existing key", () => {
const result = combiner.combineTranslations();
const message = combiner.getTranslationMessage(result.translations, "appName");
expect(message).toBe("Bitwarden");
});
it("should return null for non-existing key", () => {
const result = combiner.combineTranslations();
const message = combiner.getTranslationMessage(result.translations, "nonExistentKey");
expect(message).toBeNull();
});
it("should check if translation exists", () => {
const result = combiner.combineTranslations();
expect(combiner.hasTranslation(result.translations, "appName")).toBe(true);
expect(combiner.hasTranslation(result.translations, "nonExistentKey")).toBe(false);
});
it("should get all keys sorted", () => {
const result = combiner.combineTranslations();
const keys = combiner.getAllKeys(result.translations);
expect(keys).toBeInstanceOf(Array);
expect(keys.length).toBe(result.totalKeys);
// Should be sorted
const sortedKeys = [...keys].sort();
expect(keys).toEqual(sortedKeys);
});
it("should search keys and messages", () => {
const result = combiner.combineTranslations();
const searchResults = combiner.searchKeys(result.translations, "app");
expect(searchResults).toBeInstanceOf(Array);
expect(searchResults.length).toBeGreaterThan(0);
expect(searchResults).toContain("appName");
});
});
describe("error handling", () => {
it("should handle missing translation files gracefully", () => {
const emptyCombiner = new TranslationCombiner("/non/existent/path");
const result = emptyCombiner.combineTranslations();
expect(result.totalKeys).toBe(0);
expect(result.sources).toHaveLength(0);
expect(result.conflicts).toHaveLength(0);
});
it("should handle malformed JSON files", () => {
// Overwrite one of the existing files with malformed JSON
const badPath = path.join(testDir, "apps/browser/src/_locales/en/messages.json");
fs.writeFileSync(badPath, "{ invalid json }");
// Should not throw, but should log error
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
const result = combiner.combineTranslations();
expect(result).toBeDefined();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,250 @@
/* eslint-disable no-console */
import * as fs from "fs";
import * as path from "path";
/**
* Interface for a translation entry
*/
export interface TranslationEntry {
message: string;
description?: string;
placeholders?: Record<string, any>;
}
/**
* Interface for combined translations
*/
export interface CombinedTranslations {
[key: string]: TranslationEntry;
}
/**
* Interface for translation source information
*/
export interface TranslationSource {
app: string;
filePath: string;
keyCount: number;
}
/**
* Result of combining translations
*/
export interface CombineResult {
translations: CombinedTranslations;
sources: TranslationSource[];
conflicts: Array<{
key: string;
sources: string[];
values: string[];
}>;
totalKeys: number;
}
/**
* Service for combining translation files from multiple applications
*/
export class TranslationCombiner {
private readonly appPaths = [
"apps/browser/src/_locales/en/messages.json",
"apps/desktop/src/locales/en/messages.json",
"apps/web/src/locales/en/messages.json",
"apps/cli/src/locales/en/messages.json",
];
constructor(private rootPath: string = process.cwd()) {
// If we're in the migration directory, go up to the project root
if (this.rootPath.endsWith("scripts/migration/i18n")) {
this.rootPath = path.join(this.rootPath, "../../..");
}
}
/**
* Combine all English translation files into a single lookup
*/
combineTranslations(): CombineResult {
const combined: CombinedTranslations = {};
const sources: TranslationSource[] = [];
const conflicts: Array<{
key: string;
sources: string[];
values: string[];
}> = [];
for (const appPath of this.appPaths) {
const fullPath = path.join(this.rootPath, appPath);
if (!fs.existsSync(fullPath)) {
console.warn(`Translation file not found: ${fullPath}`);
continue;
}
try {
const content = fs.readFileSync(fullPath, "utf-8");
const translations = JSON.parse(content) as Record<string, TranslationEntry>;
const appName = this.extractAppName(appPath);
let keyCount = 0;
for (const [key, entry] of Object.entries(translations)) {
keyCount++;
if (combined[key]) {
// Handle conflicts
const existingMessage = combined[key].message;
const newMessage = entry.message;
if (existingMessage !== newMessage) {
const existingConflict = conflicts.find((c) => c.key === key);
if (existingConflict) {
if (!existingConflict.sources.includes(appName)) {
existingConflict.sources.push(appName);
existingConflict.values.push(newMessage);
}
} else {
conflicts.push({
key,
sources: [this.findSourceForKey(key, sources), appName],
values: [existingMessage, newMessage],
});
}
}
// Keep the first occurrence (or could implement priority logic)
continue;
}
combined[key] = entry;
}
sources.push({
app: appName,
filePath: fullPath,
keyCount,
});
} catch (error) {
console.error(`Error reading translation file ${fullPath}:`, error);
}
}
return {
translations: combined,
sources,
conflicts,
totalKeys: Object.keys(combined).length,
};
}
/**
* Save combined translations to a file
*/
saveCombinedTranslations(result: CombineResult, outputPath: string): void {
const outputData = {
metadata: {
generatedAt: new Date().toISOString(),
sources: result.sources,
totalKeys: result.totalKeys,
conflictCount: result.conflicts.length,
},
translations: result.translations,
};
fs.writeFileSync(outputPath, JSON.stringify(outputData, null, 2));
}
/**
* Generate a report of the combination process
*/
generateCombinationReport(result: CombineResult): string {
let report = `# Translation Combination Report\n\n`;
report += `## Summary\n`;
report += `- **Total unique keys**: ${result.totalKeys}\n`;
report += `- **Source applications**: ${result.sources.length}\n`;
report += `- **Conflicts found**: ${result.conflicts.length}\n\n`;
report += `## Sources\n`;
result.sources.forEach((source) => {
report += `- **${source.app}**: ${source.keyCount} keys\n`;
report += ` - Path: \`${source.filePath}\`\n`;
});
report += `\n`;
if (result.conflicts.length > 0) {
report += `## Conflicts\n`;
report += `The following keys have different values across applications:\n\n`;
result.conflicts.forEach((conflict) => {
report += `### \`${conflict.key}\`\n`;
conflict.sources.forEach((source, index) => {
report += `- **${source}**: "${conflict.values[index]}"\n`;
});
report += `\n`;
});
}
report += `## Key Distribution\n`;
const keysByApp = result.sources
.map((s) => ({ app: s.app, count: s.keyCount }))
.sort((a, b) => b.count - a.count);
keysByApp.forEach((item) => {
const percentage = ((item.count / result.totalKeys) * 100).toFixed(1);
report += `- **${item.app}**: ${item.count} keys (${percentage}% of total)\n`;
});
return report;
}
/**
* Extract app name from file path
*/
private extractAppName(filePath: string): string {
const parts = filePath.split("/");
const appIndex = parts.findIndex((part) => part === "apps");
return appIndex !== -1 && appIndex + 1 < parts.length ? parts[appIndex + 1] : "unknown";
}
/**
* Find which source contains a specific key
*/
private findSourceForKey(key: string, sources: TranslationSource[]): string {
// This is a simplified approach - in a real implementation,
// we'd need to track which source each key came from
return sources.length > 0 ? sources[sources.length - 1].app : "unknown";
}
/**
* Get translation message for a key
*/
getTranslationMessage(translations: CombinedTranslations, key: string): string | null {
const entry = translations[key];
return entry ? entry.message : null;
}
/**
* Check if a key exists in the combined translations
*/
hasTranslation(translations: CombinedTranslations, key: string): boolean {
return key in translations;
}
/**
* Get all available translation keys
*/
getAllKeys(translations: CombinedTranslations): string[] {
return Object.keys(translations).sort();
}
/**
* Search for keys containing a specific text
*/
searchKeys(translations: CombinedTranslations, searchText: string): string[] {
const lowerSearch = searchText.toLowerCase();
return Object.keys(translations).filter(
(key) =>
key.toLowerCase().includes(lowerSearch) ||
translations[key].message.toLowerCase().includes(lowerSearch),
);
}
}

View File

@@ -0,0 +1,230 @@
import * as fs from "fs";
import { CombinedTranslations, TranslationCombiner } from "./translation-combiner";
/**
* Service for looking up translations during template migration
*/
export class TranslationLookup {
private translations: CombinedTranslations = {};
private combiner: TranslationCombiner;
private isLoaded = false;
constructor(private rootPath: string = process.cwd()) {
this.combiner = new TranslationCombiner(rootPath);
}
/**
* Load translations from combined file or generate them
*/
async loadTranslations(combinedFilePath?: string): Promise<void> {
if (combinedFilePath && fs.existsSync(combinedFilePath)) {
// Load from existing combined file
const content = fs.readFileSync(combinedFilePath, "utf-8");
const data = JSON.parse(content);
this.translations = data.translations || data; // Handle both formats
} else {
// Generate combined translations
const result = this.combiner.combineTranslations();
this.translations = result.translations;
// Optionally save the combined file
if (combinedFilePath) {
this.combiner.saveCombinedTranslations(result, combinedFilePath);
}
}
this.isLoaded = true;
}
/**
* Get the translated message for a key
*/
getTranslation(key: string): string | null {
if (!this.isLoaded) {
throw new Error("Translations not loaded. Call loadTranslations() first.");
}
const entry = this.translations[key];
return entry ? entry.message : null;
}
/**
* Get translation with fallback to key if not found
*/
getTranslationOrKey(key: string): string {
const translation = this.getTranslation(key);
return translation || key;
}
/**
* Check if a translation exists for a key
*/
hasTranslation(key: string): boolean {
if (!this.isLoaded) {
return false;
}
return key in this.translations;
}
/**
* Get all available translation keys
*/
getAllKeys(): string[] {
if (!this.isLoaded) {
return [];
}
return Object.keys(this.translations).sort();
}
/**
* Get translation statistics
*/
getStats(): { totalKeys: number; loadedKeys: number; isLoaded: boolean } {
return {
totalKeys: Object.keys(this.translations).length,
loadedKeys: Object.keys(this.translations).length,
isLoaded: this.isLoaded,
};
}
/**
* Search for keys or translations containing text
*/
search(searchText: string): Array<{ key: string; message: string; relevance: number }> {
if (!this.isLoaded) {
return [];
}
const lowerSearch = searchText.toLowerCase();
const results: Array<{ key: string; message: string; relevance: number }> = [];
for (const [key, entry] of Object.entries(this.translations)) {
let relevance = 0;
const lowerKey = key.toLowerCase();
const lowerMessage = entry.message.toLowerCase();
// Exact key match
if (lowerKey === lowerSearch) {
relevance = 100;
}
// Key starts with search
else if (lowerKey.startsWith(lowerSearch)) {
relevance = 80;
}
// Key contains search
else if (lowerKey.includes(lowerSearch)) {
relevance = 60;
}
// Message starts with search
else if (lowerMessage.startsWith(lowerSearch)) {
relevance = 40;
}
// Message contains search
else if (lowerMessage.includes(lowerSearch)) {
relevance = 20;
}
if (relevance > 0) {
results.push({
key,
message: entry.message,
relevance,
});
}
}
return results.sort((a, b) => b.relevance - a.relevance);
}
/**
* Validate that required keys exist
*/
validateKeys(requiredKeys: string[]): { missing: string[]; found: string[] } {
if (!this.isLoaded) {
return { missing: requiredKeys, found: [] };
}
const missing: string[] = [];
const found: string[] = [];
for (const key of requiredKeys) {
if (this.hasTranslation(key)) {
found.push(key);
} else {
missing.push(key);
}
}
return { missing, found };
}
/**
* Get suggestions for a missing key based on similarity
*/
getSuggestions(
key: string,
maxSuggestions = 5,
): Array<{ key: string; message: string; similarity: number }> {
if (!this.isLoaded) {
return [];
}
const suggestions: Array<{ key: string; message: string; similarity: number }> = [];
const lowerKey = key.toLowerCase();
for (const [existingKey, entry] of Object.entries(this.translations)) {
const similarity = this.calculateSimilarity(lowerKey, existingKey.toLowerCase());
if (similarity > 0.3) {
// Only include reasonably similar keys
suggestions.push({
key: existingKey,
message: entry.message,
similarity,
});
}
}
return suggestions.sort((a, b) => b.similarity - a.similarity).slice(0, maxSuggestions);
}
/**
* Calculate string similarity using Levenshtein distance
*/
private calculateSimilarity(str1: string, str2: string): number {
const matrix: number[][] = [];
const len1 = str1.length;
const len2 = str2.length;
if (len1 === 0) {
return len2 === 0 ? 1 : 0;
}
if (len2 === 0) {
return 0;
}
// Initialize matrix
for (let i = 0; i <= len1; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= len2; j++) {
matrix[0][j] = j;
}
// Fill matrix
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost, // substitution
);
}
}
const maxLen = Math.max(len1, len2);
return (maxLen - matrix[len1][len2]) / maxLen;
}
}