i18next-mcp-server
Version:
A comprehensive Model Context Protocol (MCP) server for i18next translation management, health checking, and automated translation workflows
231 lines • 8.9 kB
JavaScript
import fs from 'node:fs/promises';
import path from 'node:path';
import chokidar from 'chokidar';
import { FileSystemError, ValidationError } from '../types/index.js';
export class FileManager {
config;
watchers = new Map();
cache = new Map();
operationsInProgress = new Set();
constructor(config) {
this.config = config;
}
getOperationKey(language, namespace) {
return `${language}:${namespace}`;
}
async loadTranslationFile(language, namespace) {
const operationKey = this.getOperationKey(language, namespace);
// Prevent concurrent operations on the same file
if (this.operationsInProgress.has(operationKey)) {
throw new Error(`Operation already in progress for ${operationKey}`);
}
this.operationsInProgress.add(operationKey);
try {
const filePath = path.join(this.config.localesPath, language, `${namespace}.json`);
const cacheKey = `${language}:${namespace}`;
try {
const stats = await fs.stat(filePath);
const cached = this.cache.get(cacheKey);
// Return cached version if file hasn't been modified
if (cached && cached.lastModified >= stats.mtime) {
return cached;
}
const content = await fs.readFile(filePath, 'utf-8');
let parsedContent;
try {
parsedContent = JSON.parse(content);
}
catch (error) {
throw new ValidationError(`Invalid JSON in ${filePath}`, { error });
}
const translationFile = {
language,
namespace,
path: filePath,
content: parsedContent,
lastModified: stats.mtime,
size: stats.size
};
this.cache.set(cacheKey, translationFile);
return translationFile;
}
catch (error) {
if (error instanceof ValidationError) {
throw error;
}
throw new FileSystemError(`Failed to load translation file: ${filePath}`, { error });
}
}
finally {
this.operationsInProgress.delete(operationKey);
}
}
async saveTranslationFile(language, namespace, content, createBackup = true) {
const filePath = path.join(this.config.localesPath, language, `${namespace}.json`);
try {
// Create backup if enabled and requested
if (createBackup && this.config.backup.enabled) {
await this.createBackup(filePath);
}
// Ensure directory exists
await fs.mkdir(path.dirname(filePath), { recursive: true });
// Sort keys for consistent output
const sortedContent = this.sortObjectKeys(content);
const jsonString = `${JSON.stringify(sortedContent, null, 2)}\n`;
await fs.writeFile(filePath, jsonString, 'utf-8');
// Update cache
const stats = await fs.stat(filePath);
const cacheKey = `${language}:${namespace}`;
this.cache.set(cacheKey, {
language,
namespace,
path: filePath,
content: sortedContent,
lastModified: stats.mtime,
size: stats.size
});
}
catch (error) {
throw new FileSystemError(`Failed to save translation file: ${filePath}`, { error });
}
}
async loadAllTranslationFiles() {
const files = [];
for (const language of this.config.languages) {
for (const namespace of this.config.namespaces) {
try {
const file = await this.loadTranslationFile(language, namespace);
files.push(file);
}
catch (error) {
// Log error but continue loading other files
console.warn(`Failed to load ${language}/${namespace}.json:`, error);
}
}
}
return files;
}
async createBackup(filePath) {
if (!this.config.backup.enabled) {
return '';
}
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const relativePath = path.relative(this.config.localesPath, filePath);
const backupPath = path.join(this.config.backup.path, timestamp, relativePath);
await fs.mkdir(path.dirname(backupPath), { recursive: true });
await fs.copyFile(filePath, backupPath);
return backupPath;
}
catch (error) {
throw new FileSystemError(`Failed to create backup for ${filePath}`, { error });
}
}
async watchFiles(callback) {
const watchPattern = path.join(this.config.localesPath, '**/*.json');
const watcher = chokidar.watch(watchPattern, {
ignored: /node_modules/,
persistent: true,
ignoreInitial: true
});
watcher.on('change', (filePath) => {
this.invalidateCache(filePath);
callback('change', filePath);
});
watcher.on('add', (filePath) => {
callback('add', filePath);
});
watcher.on('unlink', (filePath) => {
this.invalidateCache(filePath);
callback('unlink', filePath);
});
this.watchers.set(watchPattern, watcher);
}
async stopWatching() {
for (const [pattern, watcher] of this.watchers) {
await watcher.close();
this.watchers.delete(pattern);
}
}
invalidateCache(filePath) {
// Find and remove cache entries for the changed file
for (const [key, cached] of this.cache) {
if (cached.path === filePath) {
this.cache.delete(key);
break;
}
}
}
sortObjectKeys(obj) {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
const sorted = {};
const keys = Object.keys(obj).sort();
for (const key of keys) {
const value = obj[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
sorted[key] = this.sortObjectKeys(value);
}
else {
sorted[key] = value;
}
}
return sorted;
}
async ensureDirectoryStructure() {
try {
// Create base locales directory
await fs.mkdir(this.config.localesPath, { recursive: true });
// Create language directories
for (const language of this.config.languages) {
const langDir = path.join(this.config.localesPath, language);
await fs.mkdir(langDir, { recursive: true });
// Create namespace files if they don't exist
for (const namespace of this.config.namespaces) {
const filePath = path.join(langDir, `${namespace}.json`);
try {
await fs.access(filePath);
}
catch {
// File doesn't exist, create it with empty object
await fs.writeFile(filePath, '{}\n', 'utf-8');
}
}
}
// Create backup directory if enabled
if (this.config.backup.enabled) {
await fs.mkdir(this.config.backup.path, { recursive: true });
}
}
catch (error) {
throw new FileSystemError('Failed to ensure directory structure', { error });
}
}
async getFileStats() {
const files = await this.loadAllTranslationFiles();
const stats = {
totalFiles: files.length,
totalSize: files.reduce((sum, file) => sum + file.size, 0),
lastModified: new Date(Math.max(...files.map(f => f.lastModified.getTime()))),
filesByLanguage: {},
filesByNamespace: {}
};
// Count files by language and namespace
for (const file of files) {
stats.filesByLanguage[file.language] = (stats.filesByLanguage[file.language] || 0) + 1;
stats.filesByNamespace[file.namespace] = (stats.filesByNamespace[file.namespace] || 0) + 1;
}
return stats;
}
clearCache() {
this.cache.clear();
}
getCacheStats() {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys())
};
}
}
//# sourceMappingURL=file-manager.js.map