i18next-mcp-server
Version:
A comprehensive Model Context Protocol (MCP) server for i18next translation management, health checking, and automated translation workflows
353 lines • 14.3 kB
JavaScript
export class KeyManager {
config;
fileManager;
constructor(config, fileManager) {
this.config = config;
this.fileManager = fileManager;
}
/**
* Add a new translation key to specified languages and namespaces
*/
async addKey(operation) {
const results = {
success: true,
operations: [],
conflicts: [],
errors: []
};
const targetLanguages = operation.languages || this.config.languages;
const targetNamespaces = operation.namespaces || this.config.namespaces;
// Check for existing keys first
const conflicts = await this.checkKeyConflicts(operation.key, targetLanguages, targetNamespaces);
if (conflicts.length > 0 && !operation.overwrite) {
results.success = false;
results.conflicts = conflicts;
return results;
}
// Add key to each target language/namespace combination
for (const language of targetLanguages) {
for (const namespace of targetNamespaces) {
try {
const fileKey = `${language}/${namespace}`;
const translationFile = await this.fileManager.loadTranslationFile(language, namespace);
const updated = this.setNestedValue(translationFile.content, operation.key, operation.value || (language === this.config.defaultLanguage ? operation.defaultValue : ''));
await this.fileManager.saveTranslationFile(language, namespace, updated);
results.operations.push({
type: 'add',
key: operation.key,
language,
namespace,
value: operation.value || operation.defaultValue,
timestamp: new Date().toISOString()
});
}
catch (error) {
results.errors.push(`Failed to add key "${operation.key}" to ${language}/${namespace}: ${error}`);
results.success = false;
}
}
}
return results;
}
/**
* Remove a translation key from specified languages and namespaces
*/
async removeKey(operation) {
const results = {
success: true,
operations: [],
conflicts: [],
errors: []
};
const targetLanguages = operation.languages || this.config.languages;
const targetNamespaces = operation.namespaces || this.config.namespaces;
for (const language of targetLanguages) {
for (const namespace of targetNamespaces) {
try {
const fileKey = `${language}/${namespace}`;
const translationFile = await this.fileManager.loadTranslationFile(language, namespace);
const updated = this.deleteNestedValue(translationFile.content, operation.key);
await this.fileManager.saveTranslationFile(language, namespace, updated);
results.operations.push({
type: 'remove',
key: operation.key,
language,
namespace,
timestamp: new Date().toISOString()
});
}
catch (error) {
results.errors.push(`Failed to remove key "${operation.key}" from ${language}/${namespace}: ${error}`);
results.success = false;
}
}
}
return results;
}
/**
* Rename a translation key across specified languages and namespaces
*/
async renameKey(operation) {
const results = {
success: true,
operations: [],
conflicts: [],
errors: []
};
const targetLanguages = operation.languages || this.config.languages;
const targetNamespaces = operation.namespaces || this.config.namespaces;
// Check for conflicts with the new key name
const conflicts = await this.checkKeyConflicts(operation.newKey, targetLanguages, targetNamespaces);
if (conflicts.length > 0 && !operation.overwrite) {
results.success = false;
results.conflicts = conflicts;
return results;
}
for (const language of targetLanguages) {
for (const namespace of targetNamespaces) {
try {
const translationFile = await this.fileManager.loadTranslationFile(language, namespace);
// Get the existing value
const existingValue = this.getNestedValue(translationFile.content, operation.oldKey);
if (existingValue === undefined) {
results.errors.push(`Key "${operation.oldKey}" not found in ${language}/${namespace}`);
continue;
}
// Remove old key and add new key
let updated = this.deleteNestedValue(translationFile.content, operation.oldKey);
updated = this.setNestedValue(updated, operation.newKey, existingValue);
await this.fileManager.saveTranslationFile(language, namespace, updated);
results.operations.push({
type: 'rename',
key: operation.oldKey,
newKey: operation.newKey,
language,
namespace,
value: existingValue,
timestamp: new Date().toISOString()
});
}
catch (error) {
results.errors.push(`Failed to rename key "${operation.oldKey}" to "${operation.newKey}" in ${language}/${namespace}: ${error}`);
results.success = false;
}
}
}
return results;
}
/**
* Execute multiple key operations in a batch
*/
async batchOperation(operations) {
const results = {
success: true,
operations: [],
conflicts: [],
errors: []
};
// Create backup if requested
if (operations.createBackup) {
try {
await this.fileManager.createBackup(`batch-operation-${Date.now()}`);
}
catch (error) {
results.errors.push(`Failed to create backup: ${error}`);
if (!operations.continueOnError) {
results.success = false;
return results;
}
}
}
// Execute each operation
for (const operation of operations.operations) {
try {
let operationResult;
switch (operation.type) {
case 'add':
operationResult = await this.addKey(operation);
break;
case 'remove':
operationResult = await this.removeKey(operation);
break;
case 'rename':
operationResult = await this.renameKey(operation);
break;
default:
throw new Error(`Unknown operation type: ${operation.type}`);
}
// Merge results
results.operations.push(...operationResult.operations);
results.conflicts.push(...operationResult.conflicts);
results.errors.push(...operationResult.errors);
if (!operationResult.success) {
results.success = false;
if (!operations.continueOnError) {
break;
}
}
}
catch (error) {
results.errors.push(`Batch operation failed: ${error}`);
results.success = false;
if (!operations.continueOnError) {
break;
}
}
}
return results;
}
/**
* Sync keys from a source namespace to target namespaces
*/
async syncKeys(sourceNamespace, targetNamespaces, language = this.config.defaultLanguage, copyValues = false) {
const results = {
success: true,
operations: [],
conflicts: [],
errors: []
};
try {
// Get source keys
const sourceFile = await this.fileManager.loadTranslationFile(language, sourceNamespace);
if (!sourceFile) {
results.success = false;
results.errors.push(`Source namespace "${sourceNamespace}" not found`);
return results;
}
const sourceKeys = this.getAllKeys(sourceFile.content);
// Add missing keys to target namespaces
for (const targetNamespace of targetNamespaces) {
const targetFile = await this.fileManager.loadTranslationFile(language, targetNamespace);
if (!targetFile) {
results.errors.push(`Target namespace "${targetNamespace}" not found`);
continue;
}
const targetKeys = this.getAllKeys(targetFile.content);
const missingKeys = sourceKeys.filter(key => !targetKeys.includes(key));
for (const key of missingKeys) {
const sourceValue = this.getNestedValue(sourceFile.content, key);
const valueToSet = copyValues ? sourceValue : '';
const updated = this.setNestedValue(targetFile.content, key, valueToSet);
await this.fileManager.saveTranslationFile(language, targetNamespace, updated);
results.operations.push({
type: 'sync',
key,
language,
namespace: targetNamespace,
value: valueToSet,
timestamp: new Date().toISOString()
});
}
}
}
catch (error) {
results.success = false;
results.errors.push(`Sync operation failed: ${error}`);
}
return results;
}
/**
* Check for key conflicts before operations
*/
async checkKeyConflicts(key, languages, namespaces) {
const conflicts = [];
for (const language of languages) {
for (const namespace of namespaces) {
try {
const translationFile = await this.fileManager.loadTranslationFile(language, namespace);
if (translationFile && this.getNestedValue(translationFile.content, key) !== undefined) {
conflicts.push({
key,
language,
namespace,
existingValue: this.getNestedValue(translationFile.content, key)
});
}
}
catch (error) {
// Ignore errors for conflict checking
}
}
}
return conflicts;
}
/**
* Get nested value from object using dot notation
*/
getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => {
return (current && typeof current === 'object' ? current[key] : undefined);
}, obj);
}
/**
* Set nested value in object using dot notation
*/
setNestedValue(obj, path, value) {
const keys = path.split('.');
const result = JSON.parse(JSON.stringify(obj)); // Deep clone
let current = result;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
return result;
}
/**
* Delete nested value from object using dot notation
*/
deleteNestedValue(obj, path) {
const keys = path.split('.');
const result = JSON.parse(JSON.stringify(obj)); // Deep clone
let current = result;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object') {
return result; // Path doesn't exist
}
current = current[key];
}
delete current[keys[keys.length - 1]];
// Clean up empty parent objects
this.cleanupEmptyObjects(result, keys.slice(0, -1));
return result;
}
/**
* Remove empty parent objects after deletion
*/
cleanupEmptyObjects(obj, path) {
if (path.length === 0)
return;
let current = obj;
for (let i = 0; i < path.length - 1; i++) {
if (typeof current[path[i]] !== 'object')
return;
current = current[path[i]];
}
const target = current[path[path.length - 1]];
if (typeof target === 'object' && target !== null && Object.keys(target).length === 0) {
delete current[path[path.length - 1]];
this.cleanupEmptyObjects(obj, path.slice(0, -1));
}
}
/**
* Get all keys from a nested object
*/
getAllKeys(obj, prefix = '') {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
keys.push(...this.getAllKeys(value, fullKey));
}
else {
keys.push(fullKey);
}
}
return keys;
}
}
//# sourceMappingURL=key-manager.js.map