Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
Full-stack web application for Telegram management - Frontend: Vue 3 + Vben Admin - Backend: NestJS - Features: User management, group broadcast, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
33
marketing-agent/services/i18n-service/package.json
Normal file
33
marketing-agent/services/i18n-service/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "i18n-service",
|
||||
"version": "1.0.0",
|
||||
"description": "Internationalization service for multi-language support",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"mongoose": "^7.5.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"joi": "^17.10.2",
|
||||
"winston": "^3.10.0",
|
||||
"axios": "^1.5.0",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.0.0",
|
||||
"express-rate-limit": "^6.10.0",
|
||||
"i18next": "^23.5.1",
|
||||
"i18next-node-fs-backend": "^2.1.3",
|
||||
"detect-language": "^1.1.0",
|
||||
"country-language": "^0.1.7",
|
||||
"accept-language-parser": "^1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1",
|
||||
"jest": "^29.6.4",
|
||||
"@types/jest": "^29.5.4"
|
||||
}
|
||||
}
|
||||
57
marketing-agent/services/i18n-service/src/config/index.js
Normal file
57
marketing-agent/services/i18n-service/src/config/index.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
port: process.env.PORT || 3011,
|
||||
|
||||
mongodb: {
|
||||
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/i18n_service'
|
||||
},
|
||||
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
password: process.env.REDIS_PASSWORD || '',
|
||||
ttl: 3600 // 1 hour cache
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGINS ?
|
||||
process.env.CORS_ORIGINS.split(',') :
|
||||
['http://localhost:3000', 'http://localhost:8080'],
|
||||
credentials: true
|
||||
},
|
||||
|
||||
logging: {
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: process.env.LOG_FORMAT || 'json'
|
||||
},
|
||||
|
||||
i18n: {
|
||||
defaultLanguage: process.env.DEFAULT_LANGUAGE || 'en',
|
||||
fallbackLanguage: process.env.FALLBACK_LANGUAGE || 'en',
|
||||
supportedLanguages: (process.env.SUPPORTED_LANGUAGES || 'en,es,fr,de,zh,ja,ru,ar,pt,it').split(','),
|
||||
translationNamespace: 'translation',
|
||||
resourcePath: './locales'
|
||||
},
|
||||
|
||||
translation: {
|
||||
autoTranslate: process.env.AUTO_TRANSLATE === 'true',
|
||||
translationProvider: process.env.TRANSLATION_PROVIDER || 'google', // google, deepl, openai
|
||||
googleApiKey: process.env.GOOGLE_TRANSLATE_API_KEY,
|
||||
deeplApiKey: process.env.DEEPL_API_KEY,
|
||||
openaiApiKey: process.env.OPENAI_API_KEY,
|
||||
cacheTranslations: true
|
||||
},
|
||||
|
||||
detection: {
|
||||
order: ['header', 'querystring', 'cookie', 'path'],
|
||||
caches: ['cookie'],
|
||||
cookieName: 'i18next',
|
||||
lookupQuerystring: 'lang',
|
||||
lookupCookie: 'i18next',
|
||||
lookupHeader: 'accept-language',
|
||||
lookupPath: 'lang'
|
||||
}
|
||||
};
|
||||
83
marketing-agent/services/i18n-service/src/index.js
Normal file
83
marketing-agent/services/i18n-service/src/index.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import express from 'express';
|
||||
import mongoose from 'mongoose';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import { config } from './config/index.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
import translationRoutes from './routes/translations.js';
|
||||
import languageRoutes from './routes/languages.js';
|
||||
import localizationRoutes from './routes/localization.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(cors(config.cors));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path}`, {
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
headers: req.headers
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/v1/translations', translationRoutes);
|
||||
app.use('/api/v1/languages', languageRoutes);
|
||||
app.use('/api/v1/localization', localizationRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'i18n-service',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Not found'
|
||||
});
|
||||
});
|
||||
|
||||
// Connect to MongoDB and start server
|
||||
async function start() {
|
||||
try {
|
||||
await mongoose.connect(config.mongodb.uri, {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true
|
||||
});
|
||||
logger.info('Connected to MongoDB');
|
||||
|
||||
// Initialize default languages
|
||||
const { Language } = await import('./models/Language.js');
|
||||
await Language.initializeDefaults();
|
||||
logger.info('Default languages initialized');
|
||||
|
||||
app.listen(config.port, () => {
|
||||
logger.info(`I18n service listening on port ${config.port}`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start service:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
226
marketing-agent/services/i18n-service/src/models/Language.js
Normal file
226
marketing-agent/services/i18n-service/src/models/Language.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const languageSchema = new mongoose.Schema({
|
||||
code: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true,
|
||||
match: /^[a-z]{2}(-[A-Z]{2})?$/ // e.g., 'en' or 'en-US'
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
nativeName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
enum: ['ltr', 'rtl'],
|
||||
default: 'ltr'
|
||||
},
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isDefault: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
fallbackLanguage: {
|
||||
type: String,
|
||||
default: 'en'
|
||||
},
|
||||
pluralRules: {
|
||||
type: String,
|
||||
enum: ['one-other', 'zero-one-other', 'one-few-many-other', 'other'],
|
||||
default: 'one-other'
|
||||
},
|
||||
dateFormat: {
|
||||
type: String,
|
||||
default: 'MM/DD/YYYY'
|
||||
},
|
||||
timeFormat: {
|
||||
type: String,
|
||||
default: 'h:mm A'
|
||||
},
|
||||
numberFormat: {
|
||||
decimal: {
|
||||
type: String,
|
||||
default: '.'
|
||||
},
|
||||
thousand: {
|
||||
type: String,
|
||||
default: ','
|
||||
},
|
||||
currency: {
|
||||
symbol: String,
|
||||
position: {
|
||||
type: String,
|
||||
enum: ['before', 'after'],
|
||||
default: 'before'
|
||||
}
|
||||
}
|
||||
},
|
||||
translationProgress: {
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
translated: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
verified: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
lastUpdated: Date
|
||||
},
|
||||
metadata: {
|
||||
flag: String, // Flag emoji or icon
|
||||
region: String,
|
||||
script: String,
|
||||
variants: [String]
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Ensure only one default language
|
||||
languageSchema.pre('save', async function(next) {
|
||||
if (this.isDefault && this.isModified('isDefault')) {
|
||||
await mongoose.model('Language').updateMany(
|
||||
{ _id: { $ne: this._id } },
|
||||
{ isDefault: false }
|
||||
);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Methods
|
||||
languageSchema.methods.updateProgress = async function() {
|
||||
const Translation = mongoose.model('Translation');
|
||||
const total = await Translation.countDocuments({ language: this.code });
|
||||
const translated = await Translation.countDocuments({
|
||||
language: this.code,
|
||||
'metadata.source': { $ne: 'auto' }
|
||||
});
|
||||
const verified = await Translation.countDocuments({
|
||||
language: this.code,
|
||||
'metadata.verified': true
|
||||
});
|
||||
|
||||
this.translationProgress = {
|
||||
total,
|
||||
translated,
|
||||
verified,
|
||||
lastUpdated: new Date()
|
||||
};
|
||||
|
||||
return this.save();
|
||||
};
|
||||
|
||||
languageSchema.methods.getCompletionPercentage = function() {
|
||||
if (this.translationProgress.total === 0) return 0;
|
||||
return Math.round(
|
||||
(this.translationProgress.translated / this.translationProgress.total) * 100
|
||||
);
|
||||
};
|
||||
|
||||
// Statics
|
||||
languageSchema.statics.getEnabled = function() {
|
||||
return this.find({ enabled: true }).sort('name');
|
||||
};
|
||||
|
||||
languageSchema.statics.getDefault = function() {
|
||||
return this.findOne({ isDefault: true });
|
||||
};
|
||||
|
||||
languageSchema.statics.initializeDefaults = async function() {
|
||||
const defaultLanguages = [
|
||||
{
|
||||
code: 'en',
|
||||
name: 'English',
|
||||
nativeName: 'English',
|
||||
direction: 'ltr',
|
||||
isDefault: true,
|
||||
metadata: { flag: '🇺🇸' }
|
||||
},
|
||||
{
|
||||
code: 'es',
|
||||
name: 'Spanish',
|
||||
nativeName: 'Español',
|
||||
direction: 'ltr',
|
||||
metadata: { flag: '🇪🇸' }
|
||||
},
|
||||
{
|
||||
code: 'fr',
|
||||
name: 'French',
|
||||
nativeName: 'Français',
|
||||
direction: 'ltr',
|
||||
metadata: { flag: '🇫🇷' }
|
||||
},
|
||||
{
|
||||
code: 'de',
|
||||
name: 'German',
|
||||
nativeName: 'Deutsch',
|
||||
direction: 'ltr',
|
||||
metadata: { flag: '🇩🇪' }
|
||||
},
|
||||
{
|
||||
code: 'zh',
|
||||
name: 'Chinese',
|
||||
nativeName: '中文',
|
||||
direction: 'ltr',
|
||||
metadata: { flag: '🇨🇳' }
|
||||
},
|
||||
{
|
||||
code: 'ja',
|
||||
name: 'Japanese',
|
||||
nativeName: '日本語',
|
||||
direction: 'ltr',
|
||||
metadata: { flag: '🇯🇵' }
|
||||
},
|
||||
{
|
||||
code: 'ru',
|
||||
name: 'Russian',
|
||||
nativeName: 'Русский',
|
||||
direction: 'ltr',
|
||||
metadata: { flag: '🇷🇺' }
|
||||
},
|
||||
{
|
||||
code: 'ar',
|
||||
name: 'Arabic',
|
||||
nativeName: 'العربية',
|
||||
direction: 'rtl',
|
||||
metadata: { flag: '🇸🇦' }
|
||||
},
|
||||
{
|
||||
code: 'pt',
|
||||
name: 'Portuguese',
|
||||
nativeName: 'Português',
|
||||
direction: 'ltr',
|
||||
metadata: { flag: '🇵🇹' }
|
||||
},
|
||||
{
|
||||
code: 'it',
|
||||
name: 'Italian',
|
||||
nativeName: 'Italiano',
|
||||
direction: 'ltr',
|
||||
metadata: { flag: '🇮🇹' }
|
||||
}
|
||||
];
|
||||
|
||||
for (const lang of defaultLanguages) {
|
||||
await this.findOneAndUpdate(
|
||||
{ code: lang.code },
|
||||
lang,
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const Language = mongoose.model('Language', languageSchema);
|
||||
134
marketing-agent/services/i18n-service/src/models/Translation.js
Normal file
134
marketing-agent/services/i18n-service/src/models/Translation.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const translationSchema = new mongoose.Schema({
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tenant',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
key: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
namespace: {
|
||||
type: String,
|
||||
default: 'translation',
|
||||
index: true
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
context: {
|
||||
type: String,
|
||||
index: true
|
||||
},
|
||||
pluralForm: {
|
||||
type: String,
|
||||
enum: ['zero', 'one', 'few', 'many', 'other']
|
||||
},
|
||||
metadata: {
|
||||
source: {
|
||||
type: String,
|
||||
enum: ['manual', 'auto', 'import', 'api'],
|
||||
default: 'manual'
|
||||
},
|
||||
translator: String,
|
||||
translatedAt: Date,
|
||||
verified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
verifiedBy: String,
|
||||
verifiedAt: Date,
|
||||
tags: [String],
|
||||
notes: String
|
||||
},
|
||||
usage: {
|
||||
count: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
lastUsed: Date,
|
||||
contexts: [String]
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Compound index for unique translations
|
||||
translationSchema.index({ tenantId: 1, key: 1, namespace: 1, language: 1, context: 1 }, { unique: true });
|
||||
|
||||
// Text search index
|
||||
translationSchema.index({ key: 'text', value: 'text' });
|
||||
|
||||
// Multi-tenant indexes
|
||||
translationSchema.index({ tenantId: 1, key: 1, namespace: 1, language: 1, context: 1 }, { unique: true });
|
||||
translationSchema.index({ tenantId: 1, key: 'text', value: 'text' });
|
||||
|
||||
// Methods
|
||||
translationSchema.methods.incrementUsage = async function(context) {
|
||||
this.usage.count += 1;
|
||||
this.usage.lastUsed = new Date();
|
||||
if (context && !this.usage.contexts.includes(context)) {
|
||||
this.usage.contexts.push(context);
|
||||
}
|
||||
return this.save();
|
||||
};
|
||||
|
||||
translationSchema.methods.verify = async function(verifiedBy) {
|
||||
this.metadata.verified = true;
|
||||
this.metadata.verifiedBy = verifiedBy;
|
||||
this.metadata.verifiedAt = new Date();
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Statics
|
||||
translationSchema.statics.findByKey = function(key, language, namespace = 'translation') {
|
||||
return this.findOne({ key, language, namespace, isActive: true });
|
||||
};
|
||||
|
||||
translationSchema.statics.findByLanguage = function(language, namespace = 'translation') {
|
||||
return this.find({ language, namespace, isActive: true });
|
||||
};
|
||||
|
||||
translationSchema.statics.getTranslations = async function(keys, language, namespace = 'translation') {
|
||||
const translations = await this.find({
|
||||
key: { $in: keys },
|
||||
language,
|
||||
namespace,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
// Convert to key-value object
|
||||
return translations.reduce((acc, trans) => {
|
||||
acc[trans.key] = trans.value;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
translationSchema.statics.getMissingTranslations = async function(language, namespace = 'translation') {
|
||||
// Find all unique keys
|
||||
const allKeys = await this.distinct('key', { namespace });
|
||||
|
||||
// Find existing translations for the language
|
||||
const existingTranslations = await this.find({ language, namespace });
|
||||
const existingKeys = existingTranslations.map(t => t.key);
|
||||
|
||||
// Return missing keys
|
||||
return allKeys.filter(key => !existingKeys.includes(key));
|
||||
};
|
||||
|
||||
export const Translation = mongoose.model('Translation', translationSchema);
|
||||
304
marketing-agent/services/i18n-service/src/routes/languages.js
Normal file
304
marketing-agent/services/i18n-service/src/routes/languages.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import express from 'express';
|
||||
import Joi from 'joi';
|
||||
import { Language } from '../models/Language.js';
|
||||
import { localizationService } from '../services/localizationService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const languageSchema = Joi.object({
|
||||
code: Joi.string().pattern(/^[a-z]{2}(-[A-Z]{2})?$/).required(),
|
||||
name: Joi.string().required(),
|
||||
nativeName: Joi.string().required(),
|
||||
direction: Joi.string().valid('ltr', 'rtl').default('ltr'),
|
||||
enabled: Joi.boolean().default(true),
|
||||
isDefault: Joi.boolean().default(false),
|
||||
fallbackLanguage: Joi.string().default('en'),
|
||||
dateFormat: Joi.string(),
|
||||
timeFormat: Joi.string(),
|
||||
numberFormat: Joi.object({
|
||||
decimal: Joi.string(),
|
||||
thousand: Joi.string(),
|
||||
currency: Joi.object({
|
||||
symbol: Joi.string(),
|
||||
position: Joi.string().valid('before', 'after')
|
||||
})
|
||||
}),
|
||||
metadata: Joi.object({
|
||||
flag: Joi.string(),
|
||||
region: Joi.string()
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/languages
|
||||
* Get all languages
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { enabled } = req.query;
|
||||
|
||||
let query = {};
|
||||
if (enabled !== undefined) {
|
||||
query.enabled = enabled === 'true';
|
||||
}
|
||||
|
||||
const languages = await Language.find(query).sort('name');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
languages,
|
||||
total: languages.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get languages:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get languages'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/languages/enabled
|
||||
* Get enabled languages with completion status
|
||||
*/
|
||||
router.get('/enabled', async (req, res) => {
|
||||
try {
|
||||
const languages = await localizationService.getEnabledLanguages();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
languages
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get enabled languages:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get enabled languages'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/languages/detect
|
||||
* Detect user's preferred language
|
||||
*/
|
||||
router.get('/detect', async (req, res) => {
|
||||
try {
|
||||
const detectedLanguage = await localizationService.detectLanguage(req);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
language: detectedLanguage,
|
||||
headers: {
|
||||
acceptLanguage: req.headers['accept-language'],
|
||||
country: req.headers['cf-ipcountry']
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to detect language:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to detect language'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/languages/:code
|
||||
* Get language by code
|
||||
*/
|
||||
router.get('/:code', async (req, res) => {
|
||||
try {
|
||||
const language = await Language.findOne({ code: req.params.code });
|
||||
|
||||
if (!language) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Language not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
language
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get language:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get language'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/languages
|
||||
* Create new language
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { error, value } = languageSchema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
const language = await Language.create(value);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
language
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 11000) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Language code already exists'
|
||||
});
|
||||
}
|
||||
|
||||
logger.error('Failed to create language:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create language'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/v1/languages/:code
|
||||
* Update language
|
||||
*/
|
||||
router.put('/:code', async (req, res) => {
|
||||
try {
|
||||
const language = await Language.findOne({ code: req.params.code });
|
||||
|
||||
if (!language) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Language not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Don't allow changing the code
|
||||
delete req.body.code;
|
||||
|
||||
Object.assign(language, req.body);
|
||||
await language.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
language
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update language:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update language'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/v1/languages/:code/progress
|
||||
* Update translation progress
|
||||
*/
|
||||
router.put('/:code/progress', async (req, res) => {
|
||||
try {
|
||||
const language = await Language.findOne({ code: req.params.code });
|
||||
|
||||
if (!language) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Language not found'
|
||||
});
|
||||
}
|
||||
|
||||
await language.updateProgress();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
language,
|
||||
progress: {
|
||||
percentage: language.getCompletionPercentage(),
|
||||
...language.translationProgress
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update progress:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update progress'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/languages/initialize
|
||||
* Initialize default languages
|
||||
*/
|
||||
router.post('/initialize', async (req, res) => {
|
||||
try {
|
||||
await Language.initializeDefaults();
|
||||
|
||||
const languages = await Language.find();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Default languages initialized',
|
||||
languages,
|
||||
total: languages.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize languages:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to initialize languages'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/languages/:code
|
||||
* Delete language (disable it)
|
||||
*/
|
||||
router.delete('/:code', async (req, res) => {
|
||||
try {
|
||||
const language = await Language.findOne({ code: req.params.code });
|
||||
|
||||
if (!language) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Language not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (language.isDefault) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cannot delete default language'
|
||||
});
|
||||
}
|
||||
|
||||
language.enabled = false;
|
||||
await language.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Language disabled successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete language:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to delete language'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
261
marketing-agent/services/i18n-service/src/routes/localization.js
Normal file
261
marketing-agent/services/i18n-service/src/routes/localization.js
Normal file
@@ -0,0 +1,261 @@
|
||||
import express from 'express';
|
||||
import { localizationService } from '../services/localizationService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/v1/localization/format/number
|
||||
* Format number according to locale
|
||||
*/
|
||||
router.post('/format/number', async (req, res) => {
|
||||
try {
|
||||
const { number, language, options = {} } = req.body;
|
||||
|
||||
if (number === undefined || !language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'number and language are required'
|
||||
});
|
||||
}
|
||||
|
||||
const formatted = localizationService.formatNumber(
|
||||
number,
|
||||
language,
|
||||
options
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
formatted,
|
||||
original: number,
|
||||
language
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to format number:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to format number'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/localization/format/currency
|
||||
* Format currency according to locale
|
||||
*/
|
||||
router.post('/format/currency', async (req, res) => {
|
||||
try {
|
||||
const { amount, language, currency = 'USD' } = req.body;
|
||||
|
||||
if (amount === undefined || !language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'amount and language are required'
|
||||
});
|
||||
}
|
||||
|
||||
const formatted = localizationService.formatCurrency(
|
||||
amount,
|
||||
language,
|
||||
currency
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
formatted,
|
||||
original: amount,
|
||||
language,
|
||||
currency
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to format currency:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to format currency'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/localization/format/date
|
||||
* Format date according to locale
|
||||
*/
|
||||
router.post('/format/date', async (req, res) => {
|
||||
try {
|
||||
const { date, language, options = {} } = req.body;
|
||||
|
||||
if (!date || !language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'date and language are required'
|
||||
});
|
||||
}
|
||||
|
||||
const formatted = localizationService.formatDate(
|
||||
date,
|
||||
language,
|
||||
options
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
formatted,
|
||||
original: date,
|
||||
language
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to format date:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to format date'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/localization/format/relative-time
|
||||
* Format relative time
|
||||
*/
|
||||
router.post('/format/relative-time', async (req, res) => {
|
||||
try {
|
||||
const { date, language } = req.body;
|
||||
|
||||
if (!date || !language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'date and language are required'
|
||||
});
|
||||
}
|
||||
|
||||
const formatted = localizationService.formatRelativeTime(
|
||||
date,
|
||||
language
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
formatted,
|
||||
original: date,
|
||||
language
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to format relative time:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to format relative time'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/localization/sort
|
||||
* Sort array according to locale
|
||||
*/
|
||||
router.post('/sort', async (req, res) => {
|
||||
try {
|
||||
const { items, language, key } = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || !language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'items (array) and language are required'
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = localizationService.sortByLocale(
|
||||
[...items], // Don't mutate original
|
||||
language,
|
||||
key
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sorted,
|
||||
original: items,
|
||||
language
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to sort items:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to sort items'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/localization/direction/:language
|
||||
* Get text direction for language
|
||||
*/
|
||||
router.get('/direction/:language', async (req, res) => {
|
||||
try {
|
||||
const direction = await localizationService.getTextDirection(
|
||||
req.params.language
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
language: req.params.language,
|
||||
direction
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get text direction:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get text direction'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/localization/batch-format
|
||||
* Batch format multiple values
|
||||
*/
|
||||
router.post('/batch-format', async (req, res) => {
|
||||
try {
|
||||
const { items, language } = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || !language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'items (array) and language are required'
|
||||
});
|
||||
}
|
||||
|
||||
const results = items.map(item => {
|
||||
const { type, value, options = {} } = item;
|
||||
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return localizationService.formatNumber(value, language, options);
|
||||
case 'currency':
|
||||
return localizationService.formatCurrency(
|
||||
value,
|
||||
language,
|
||||
options.currency || 'USD'
|
||||
);
|
||||
case 'date':
|
||||
return localizationService.formatDate(value, language, options);
|
||||
case 'relative-time':
|
||||
return localizationService.formatRelativeTime(value, language);
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
results,
|
||||
language
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to batch format:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to batch format'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
349
marketing-agent/services/i18n-service/src/routes/translations.js
Normal file
349
marketing-agent/services/i18n-service/src/routes/translations.js
Normal file
@@ -0,0 +1,349 @@
|
||||
import express from 'express';
|
||||
import Joi from 'joi';
|
||||
import { Translation } from '../models/Translation.js';
|
||||
import { translationService } from '../services/translationService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const translationSchema = Joi.object({
|
||||
key: Joi.string().required(),
|
||||
language: Joi.string().required(),
|
||||
value: Joi.string().required(),
|
||||
namespace: Joi.string().default('translation'),
|
||||
context: Joi.string().allow(null),
|
||||
metadata: Joi.object({
|
||||
notes: Joi.string(),
|
||||
tags: Joi.array().items(Joi.string())
|
||||
})
|
||||
});
|
||||
|
||||
const bulkTranslationSchema = Joi.object({
|
||||
translations: Joi.object().pattern(
|
||||
Joi.string(),
|
||||
Joi.string()
|
||||
).required(),
|
||||
language: Joi.string().required(),
|
||||
namespace: Joi.string().default('translation'),
|
||||
overwrite: Joi.boolean().default(false)
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/translations
|
||||
* Get translation(s)
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { key, keys, language, namespace = 'translation' } = req.query;
|
||||
|
||||
if (!language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Language parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Single key
|
||||
if (key) {
|
||||
const translation = await translationService.getTranslation(
|
||||
key,
|
||||
language,
|
||||
{ namespace }
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
translation: {
|
||||
key,
|
||||
value: translation,
|
||||
language,
|
||||
namespace
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Multiple keys
|
||||
if (keys) {
|
||||
const keyArray = Array.isArray(keys) ? keys : keys.split(',');
|
||||
const translations = await translationService.getTranslations(
|
||||
keyArray,
|
||||
language,
|
||||
{ namespace }
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
translations,
|
||||
language,
|
||||
namespace
|
||||
});
|
||||
}
|
||||
|
||||
// All translations for language
|
||||
const translations = await Translation.find({
|
||||
language,
|
||||
namespace,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
translations,
|
||||
total: translations.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get translations:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get translations'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/translations
|
||||
* Create or update translation
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { error, value } = translationSchema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
const translation = await translationService.setTranslation(
|
||||
value.key,
|
||||
value.language,
|
||||
value.value,
|
||||
{
|
||||
namespace: value.namespace,
|
||||
context: value.context,
|
||||
metadata: value.metadata
|
||||
}
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
translation
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to create translation:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create translation'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/translations/bulk
|
||||
* Import multiple translations
|
||||
*/
|
||||
router.post('/bulk', async (req, res) => {
|
||||
try {
|
||||
const { error, value } = bulkTranslationSchema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
const results = await translationService.importTranslations(
|
||||
value.translations,
|
||||
value.language,
|
||||
{
|
||||
namespace: value.namespace,
|
||||
overwrite: value.overwrite
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to import translations:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to import translations'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/translations/export
|
||||
* Export translations
|
||||
*/
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const { language, namespace = 'translation', format = 'json' } = req.query;
|
||||
|
||||
if (!language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Language parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
const data = await translationService.exportTranslations(language, {
|
||||
namespace,
|
||||
format
|
||||
});
|
||||
|
||||
const contentType = format === 'csv' ? 'text/csv' : 'application/json';
|
||||
const filename = `translations_${language}_${namespace}.${format}`;
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to export translations:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to export translations'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/translations/translate
|
||||
* Auto-translate text
|
||||
*/
|
||||
router.post('/translate', async (req, res) => {
|
||||
try {
|
||||
const { text, sourceLang, targetLang } = req.body;
|
||||
|
||||
if (!text || !sourceLang || !targetLang) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'text, sourceLang, and targetLang are required'
|
||||
});
|
||||
}
|
||||
|
||||
const translatedText = await translationService.autoTranslate(
|
||||
text,
|
||||
sourceLang,
|
||||
targetLang
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
translation: {
|
||||
original: text,
|
||||
translated: translatedText,
|
||||
sourceLang,
|
||||
targetLang
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to auto-translate:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to auto-translate'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/translations/missing
|
||||
* Get missing translations for a language
|
||||
*/
|
||||
router.get('/missing', async (req, res) => {
|
||||
try {
|
||||
const { language, namespace = 'translation' } = req.query;
|
||||
|
||||
if (!language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Language parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
const missingKeys = await Translation.getMissingTranslations(
|
||||
language,
|
||||
namespace
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
language,
|
||||
namespace,
|
||||
missingKeys,
|
||||
total: missingKeys.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get missing translations:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get missing translations'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/v1/translations/:id/verify
|
||||
* Verify a translation
|
||||
*/
|
||||
router.put('/:id/verify', async (req, res) => {
|
||||
try {
|
||||
const translation = await Translation.findById(req.params.id);
|
||||
|
||||
if (!translation) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Translation not found'
|
||||
});
|
||||
}
|
||||
|
||||
await translation.verify(req.user?.id || 'system');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
translation
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify translation:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to verify translation'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/translations/:id
|
||||
* Delete a translation
|
||||
*/
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const translation = await Translation.findById(req.params.id);
|
||||
|
||||
if (!translation) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Translation not found'
|
||||
});
|
||||
}
|
||||
|
||||
translation.isActive = false;
|
||||
await translation.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Translation deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete translation:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to delete translation'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,228 @@
|
||||
import { Language } from '../models/Language.js';
|
||||
import acceptLanguageParser from 'accept-language-parser';
|
||||
import { config } from '../config/index.js';
|
||||
|
||||
export class LocalizationService {
|
||||
constructor() {
|
||||
this.numberFormatters = new Map();
|
||||
this.dateFormatters = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect user's preferred language
|
||||
*/
|
||||
async detectLanguage(req) {
|
||||
// 1. Check explicit language parameter
|
||||
if (req.query.lang) {
|
||||
const lang = await Language.findOne({ code: req.query.lang, enabled: true });
|
||||
if (lang) return lang.code;
|
||||
}
|
||||
|
||||
// 2. Check cookie
|
||||
if (req.cookies && req.cookies[config.detection.cookieName]) {
|
||||
const lang = await Language.findOne({
|
||||
code: req.cookies[config.detection.cookieName],
|
||||
enabled: true
|
||||
});
|
||||
if (lang) return lang.code;
|
||||
}
|
||||
|
||||
// 3. Check Accept-Language header
|
||||
if (req.headers['accept-language']) {
|
||||
const acceptedLanguages = acceptLanguageParser.parse(
|
||||
req.headers['accept-language']
|
||||
);
|
||||
|
||||
const enabledLanguages = await Language.find({ enabled: true });
|
||||
const enabledCodes = enabledLanguages.map(l => l.code);
|
||||
|
||||
for (const acceptedLang of acceptedLanguages) {
|
||||
// Try exact match
|
||||
if (enabledCodes.includes(acceptedLang.code)) {
|
||||
return acceptedLang.code;
|
||||
}
|
||||
|
||||
// Try language without region
|
||||
const langOnly = acceptedLang.code.split('-')[0];
|
||||
if (enabledCodes.includes(langOnly)) {
|
||||
return langOnly;
|
||||
}
|
||||
|
||||
// Try to find any variant of the language
|
||||
const variant = enabledCodes.find(code =>
|
||||
code.startsWith(langOnly + '-')
|
||||
);
|
||||
if (variant) return variant;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check user's location (if available)
|
||||
if (req.headers['cf-ipcountry']) {
|
||||
// Cloudflare country code
|
||||
const countryLang = await this.getLanguageByCountry(
|
||||
req.headers['cf-ipcountry']
|
||||
);
|
||||
if (countryLang) return countryLang;
|
||||
}
|
||||
|
||||
// 5. Return default language
|
||||
const defaultLang = await Language.getDefault();
|
||||
return defaultLang ? defaultLang.code : config.i18n.defaultLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language by country code
|
||||
*/
|
||||
async getLanguageByCountry(countryCode) {
|
||||
const countryLanguages = {
|
||||
US: 'en', GB: 'en', CA: 'en', AU: 'en',
|
||||
ES: 'es', MX: 'es', AR: 'es', CO: 'es',
|
||||
FR: 'fr', BE: 'fr', CH: 'fr',
|
||||
DE: 'de', AT: 'de',
|
||||
CN: 'zh', TW: 'zh', HK: 'zh',
|
||||
JP: 'ja',
|
||||
RU: 'ru',
|
||||
SA: 'ar', AE: 'ar', EG: 'ar',
|
||||
BR: 'pt', PT: 'pt',
|
||||
IT: 'it'
|
||||
};
|
||||
|
||||
const langCode = countryLanguages[countryCode];
|
||||
if (!langCode) return null;
|
||||
|
||||
const lang = await Language.findOne({ code: langCode, enabled: true });
|
||||
return lang ? lang.code : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number according to locale
|
||||
*/
|
||||
formatNumber(number, language, options = {}) {
|
||||
const cacheKey = `${language}:${JSON.stringify(options)}`;
|
||||
|
||||
if (!this.numberFormatters.has(cacheKey)) {
|
||||
this.numberFormatters.set(
|
||||
cacheKey,
|
||||
new Intl.NumberFormat(this.getLocaleCode(language), options)
|
||||
);
|
||||
}
|
||||
|
||||
return this.numberFormatters.get(cacheKey).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency according to locale
|
||||
*/
|
||||
formatCurrency(amount, language, currency = 'USD') {
|
||||
return this.formatNumber(amount, language, {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date according to locale
|
||||
*/
|
||||
formatDate(date, language, options = {}) {
|
||||
const cacheKey = `${language}:${JSON.stringify(options)}`;
|
||||
|
||||
if (!this.dateFormatters.has(cacheKey)) {
|
||||
this.dateFormatters.set(
|
||||
cacheKey,
|
||||
new Intl.DateTimeFormat(this.getLocaleCode(language), options)
|
||||
);
|
||||
}
|
||||
|
||||
return this.dateFormatters.get(cacheKey).format(new Date(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
formatRelativeTime(date, language) {
|
||||
const rtf = new Intl.RelativeTimeFormat(this.getLocaleCode(language), {
|
||||
numeric: 'auto'
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const target = new Date(date);
|
||||
const diffInSeconds = Math.floor((target - now) / 1000);
|
||||
|
||||
const units = [
|
||||
{ unit: 'year', seconds: 31536000 },
|
||||
{ unit: 'month', seconds: 2592000 },
|
||||
{ unit: 'week', seconds: 604800 },
|
||||
{ unit: 'day', seconds: 86400 },
|
||||
{ unit: 'hour', seconds: 3600 },
|
||||
{ unit: 'minute', seconds: 60 },
|
||||
{ unit: 'second', seconds: 1 }
|
||||
];
|
||||
|
||||
for (const { unit, seconds } of units) {
|
||||
const diff = Math.floor(diffInSeconds / seconds);
|
||||
if (Math.abs(diff) >= 1) {
|
||||
return rtf.format(diff, unit);
|
||||
}
|
||||
}
|
||||
|
||||
return rtf.format(0, 'second');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale code for Intl APIs
|
||||
*/
|
||||
getLocaleCode(language) {
|
||||
const localeMap = {
|
||||
en: 'en-US',
|
||||
es: 'es-ES',
|
||||
fr: 'fr-FR',
|
||||
de: 'de-DE',
|
||||
zh: 'zh-CN',
|
||||
ja: 'ja-JP',
|
||||
ru: 'ru-RU',
|
||||
ar: 'ar-SA',
|
||||
pt: 'pt-BR',
|
||||
it: 'it-IT'
|
||||
};
|
||||
|
||||
return localeMap[language] || language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text direction for language
|
||||
*/
|
||||
async getTextDirection(language) {
|
||||
const lang = await Language.findOne({ code: language });
|
||||
return lang ? lang.direction : 'ltr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled languages
|
||||
*/
|
||||
async getEnabledLanguages() {
|
||||
const languages = await Language.getEnabled();
|
||||
return languages.map(lang => ({
|
||||
code: lang.code,
|
||||
name: lang.name,
|
||||
nativeName: lang.nativeName,
|
||||
direction: lang.direction,
|
||||
flag: lang.metadata?.flag,
|
||||
completion: lang.getCompletionPercentage()
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort text according to locale
|
||||
*/
|
||||
sortByLocale(items, language, key) {
|
||||
const collator = new Intl.Collator(this.getLocaleCode(language));
|
||||
|
||||
return items.sort((a, b) => {
|
||||
const aValue = key ? a[key] : a;
|
||||
const bValue = key ? b[key] : b;
|
||||
return collator.compare(aValue, bValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const localizationService = new LocalizationService();
|
||||
@@ -0,0 +1,377 @@
|
||||
import { Translation } from '../models/Translation.js';
|
||||
import { Language } from '../models/Language.js';
|
||||
import { config } from '../config/index.js';
|
||||
import axios from 'axios';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export class TranslationService {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
this.providers = {
|
||||
google: this.googleTranslate.bind(this),
|
||||
deepl: this.deeplTranslate.bind(this),
|
||||
openai: this.openaiTranslate.bind(this)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation for a key
|
||||
*/
|
||||
async getTranslation(key, language, options = {}) {
|
||||
const {
|
||||
namespace = 'translation',
|
||||
context = null,
|
||||
defaultValue = key,
|
||||
variables = {},
|
||||
count = null
|
||||
} = options;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `${language}:${namespace}:${key}:${context || 'default'}`;
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.interpolate(this.cache.get(cacheKey), variables);
|
||||
}
|
||||
|
||||
// Find translation
|
||||
let translation = await Translation.findOne({
|
||||
key,
|
||||
language,
|
||||
namespace,
|
||||
context: context || { $exists: false },
|
||||
isActive: true
|
||||
});
|
||||
|
||||
// Handle pluralization
|
||||
if (count !== null && translation) {
|
||||
const pluralForm = this.getPluralForm(language, count);
|
||||
const pluralTranslation = await Translation.findOne({
|
||||
key,
|
||||
language,
|
||||
namespace,
|
||||
context,
|
||||
pluralForm,
|
||||
isActive: true
|
||||
});
|
||||
if (pluralTranslation) {
|
||||
translation = pluralTranslation;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default language
|
||||
if (!translation) {
|
||||
const defaultLang = await Language.getDefault();
|
||||
if (defaultLang && defaultLang.code !== language) {
|
||||
translation = await Translation.findOne({
|
||||
key,
|
||||
language: defaultLang.code,
|
||||
namespace,
|
||||
context: context || { $exists: false },
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-translate if enabled and not found
|
||||
if (!translation && config.translation.autoTranslate) {
|
||||
const sourceTranslation = await Translation.findOne({
|
||||
key,
|
||||
language: config.i18n.defaultLanguage,
|
||||
namespace,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (sourceTranslation) {
|
||||
try {
|
||||
const translatedValue = await this.autoTranslate(
|
||||
sourceTranslation.value,
|
||||
config.i18n.defaultLanguage,
|
||||
language
|
||||
);
|
||||
|
||||
translation = await Translation.create({
|
||||
key,
|
||||
namespace,
|
||||
language,
|
||||
value: translatedValue,
|
||||
context,
|
||||
metadata: {
|
||||
source: 'auto',
|
||||
translatedAt: new Date()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Auto-translation failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const value = translation ? translation.value : defaultValue;
|
||||
|
||||
// Cache the result
|
||||
if (config.translation.cacheTranslations) {
|
||||
this.cache.set(cacheKey, value);
|
||||
}
|
||||
|
||||
// Update usage statistics
|
||||
if (translation) {
|
||||
translation.incrementUsage(options.usageContext);
|
||||
}
|
||||
|
||||
return this.interpolate(value, variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple translations
|
||||
*/
|
||||
async getTranslations(keys, language, options = {}) {
|
||||
const { namespace = 'translation' } = options;
|
||||
|
||||
const translations = await Translation.getTranslations(keys, language, namespace);
|
||||
const result = {};
|
||||
|
||||
for (const key of keys) {
|
||||
result[key] = translations[key] || key;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set translation
|
||||
*/
|
||||
async setTranslation(key, language, value, options = {}) {
|
||||
const {
|
||||
namespace = 'translation',
|
||||
context = null,
|
||||
metadata = {}
|
||||
} = options;
|
||||
|
||||
const translation = await Translation.findOneAndUpdate(
|
||||
{ key, language, namespace, context },
|
||||
{
|
||||
value,
|
||||
metadata: {
|
||||
...metadata,
|
||||
source: metadata.source || 'manual',
|
||||
translatedAt: new Date()
|
||||
},
|
||||
isActive: true
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
// Clear cache
|
||||
const cacheKey = `${language}:${namespace}:${key}:${context || 'default'}`;
|
||||
this.cache.delete(cacheKey);
|
||||
|
||||
return translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk import translations
|
||||
*/
|
||||
async importTranslations(translations, language, options = {}) {
|
||||
const { namespace = 'translation', overwrite = false } = options;
|
||||
const results = { imported: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (const [key, value] of Object.entries(translations)) {
|
||||
try {
|
||||
const exists = await Translation.findOne({ key, language, namespace });
|
||||
|
||||
if (exists && !overwrite) {
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.setTranslation(key, language, value, {
|
||||
namespace,
|
||||
metadata: { source: 'import' }
|
||||
});
|
||||
|
||||
results.imported++;
|
||||
} catch (error) {
|
||||
results.errors.push({ key, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export translations
|
||||
*/
|
||||
async exportTranslations(language, options = {}) {
|
||||
const { namespace = 'translation', format = 'json' } = options;
|
||||
|
||||
const translations = await Translation.find({
|
||||
language,
|
||||
namespace,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
const data = translations.reduce((acc, trans) => {
|
||||
if (trans.context) {
|
||||
acc[`${trans.key}_${trans.context}`] = trans.value;
|
||||
} else {
|
||||
acc[trans.key] = trans.value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (format === 'json') {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} else if (format === 'csv') {
|
||||
const csv = ['key,value'];
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
csv.push(`"${key}","${value.replace(/"/g, '""')}"`);
|
||||
}
|
||||
return csv.join('\n');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-translate using configured provider
|
||||
*/
|
||||
async autoTranslate(text, sourceLang, targetLang) {
|
||||
const provider = this.providers[config.translation.translationProvider];
|
||||
if (!provider) {
|
||||
throw new Error(`Translation provider ${config.translation.translationProvider} not supported`);
|
||||
}
|
||||
|
||||
return provider(text, sourceLang, targetLang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Translate
|
||||
*/
|
||||
async googleTranslate(text, sourceLang, targetLang) {
|
||||
if (!config.translation.googleApiKey) {
|
||||
throw new Error('Google Translate API key not configured');
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`https://translation.googleapis.com/language/translate/v2`,
|
||||
{
|
||||
q: text,
|
||||
source: sourceLang,
|
||||
target: targetLang,
|
||||
format: 'text'
|
||||
},
|
||||
{
|
||||
params: { key: config.translation.googleApiKey }
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.data.translations[0].translatedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepL Translate
|
||||
*/
|
||||
async deeplTranslate(text, sourceLang, targetLang) {
|
||||
if (!config.translation.deeplApiKey) {
|
||||
throw new Error('DeepL API key not configured');
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
'https://api-free.deepl.com/v2/translate',
|
||||
new URLSearchParams({
|
||||
auth_key: config.translation.deeplApiKey,
|
||||
text: text,
|
||||
source_lang: sourceLang.toUpperCase(),
|
||||
target_lang: targetLang.toUpperCase()
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.translations[0].text;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Translate
|
||||
*/
|
||||
async openaiTranslate(text, sourceLang, targetLang) {
|
||||
if (!config.translation.openaiApiKey) {
|
||||
throw new Error('OpenAI API key not configured');
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
'https://api.openai.com/v1/chat/completions',
|
||||
{
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a professional translator. Translate the following text from ${sourceLang} to ${targetLang}. Only return the translation, nothing else.`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: text
|
||||
}
|
||||
],
|
||||
temperature: 0.3
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.translation.openaiApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.choices[0].message.content.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate variables in translation
|
||||
*/
|
||||
interpolate(text, variables = {}) {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return variables[key] !== undefined ? variables[key] : match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plural form for a language and count
|
||||
*/
|
||||
getPluralForm(language, count) {
|
||||
// Simplified plural rules - in production, use a proper library
|
||||
const rules = {
|
||||
en: (n) => n === 1 ? 'one' : 'other',
|
||||
es: (n) => n === 1 ? 'one' : 'other',
|
||||
fr: (n) => n === 0 || n === 1 ? 'one' : 'other',
|
||||
de: (n) => n === 1 ? 'one' : 'other',
|
||||
zh: () => 'other',
|
||||
ja: () => 'other',
|
||||
ru: (n) => {
|
||||
if (n % 10 === 1 && n % 100 !== 11) return 'one';
|
||||
if ([2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100)) return 'few';
|
||||
return 'many';
|
||||
},
|
||||
ar: (n) => {
|
||||
if (n === 0) return 'zero';
|
||||
if (n === 1) return 'one';
|
||||
if (n === 2) return 'two';
|
||||
if (n % 100 >= 3 && n % 100 <= 10) return 'few';
|
||||
if (n % 100 >= 11) return 'many';
|
||||
return 'other';
|
||||
}
|
||||
};
|
||||
|
||||
const rule = rules[language] || rules.en;
|
||||
return rule(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear translation cache
|
||||
*/
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const translationService = new TranslationService();
|
||||
33
marketing-agent/services/i18n-service/src/utils/logger.js
Normal file
33
marketing-agent/services/i18n-service/src/utils/logger.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import winston from 'winston';
|
||||
import { config } from '../config/index.js';
|
||||
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: config.logging.level,
|
||||
format: logFormat,
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Add file transport in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
logger.add(new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error'
|
||||
}));
|
||||
|
||||
logger.add(new winston.transports.File({
|
||||
filename: 'logs/combined.log'
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user