generated from fahricansecer/boilerplate-be
This commit is contained in:
512
src/modules/i18n/services/translation-management.service.ts
Normal file
512
src/modules/i18n/services/translation-management.service.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
// Translation Management Service - Admin translation management
|
||||
// Path: src/modules/i18n/services/translation-management.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
|
||||
export interface TranslationKey {
|
||||
id: string;
|
||||
key: string;
|
||||
namespace: string;
|
||||
description?: string;
|
||||
context?: string;
|
||||
translations: TranslationRecord[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TranslationRecord {
|
||||
id: string;
|
||||
keyId: string;
|
||||
locale: string;
|
||||
value: string;
|
||||
status: 'draft' | 'review' | 'approved' | 'published';
|
||||
translatedBy?: string;
|
||||
reviewedBy?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TranslationExport {
|
||||
locale: string;
|
||||
namespace?: string;
|
||||
format: 'json' | 'csv' | 'xliff' | 'po';
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface TranslationStats {
|
||||
totalKeys: number;
|
||||
byNamespace: Record<string, number>;
|
||||
byLocale: Record<string, {
|
||||
translated: number;
|
||||
pending: number;
|
||||
percentage: number;
|
||||
}>;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TranslationManagementService {
|
||||
private readonly logger = new Logger(TranslationManagementService.name);
|
||||
|
||||
// In-memory storage (in production, use database)
|
||||
private readonly translationKeys = new Map<string, TranslationKey>();
|
||||
private readonly translationRecords = new Map<string, TranslationRecord>();
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
this.initializeDefaultKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default translation keys
|
||||
*/
|
||||
private initializeDefaultKeys(): void {
|
||||
const defaultNamespaces = ['common', 'auth', 'content', 'analytics', 'errors'];
|
||||
const defaultLocales = ['en', 'tr', 'es', 'fr', 'de', 'zh', 'pt', 'ar', 'ru', 'ja'];
|
||||
|
||||
let keyIndex = 0;
|
||||
for (const namespace of defaultNamespaces) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const keyId = `key-${keyIndex++}`;
|
||||
const key: TranslationKey = {
|
||||
id: keyId,
|
||||
key: `${namespace}.item${i}`,
|
||||
namespace,
|
||||
description: `Translation for ${namespace} item ${i}`,
|
||||
translations: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Create translation records for each locale
|
||||
for (const locale of defaultLocales) {
|
||||
const recordId = `record-${keyId}-${locale}`;
|
||||
const record: TranslationRecord = {
|
||||
id: recordId,
|
||||
keyId,
|
||||
locale,
|
||||
value: `[${locale.toUpperCase()}] ${namespace}.item${i}`,
|
||||
status: 'published',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.translationRecords.set(recordId, record);
|
||||
key.translations.push(record);
|
||||
}
|
||||
|
||||
this.translationKeys.set(keyId, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== KEY MANAGEMENT ==========
|
||||
|
||||
/**
|
||||
* Create a new translation key
|
||||
*/
|
||||
createKey(input: {
|
||||
key: string;
|
||||
namespace: string;
|
||||
description?: string;
|
||||
context?: string;
|
||||
}): TranslationKey {
|
||||
const id = `key-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
const translationKey: TranslationKey = {
|
||||
id,
|
||||
key: input.key,
|
||||
namespace: input.namespace,
|
||||
description: input.description,
|
||||
context: input.context,
|
||||
translations: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.translationKeys.set(id, translationKey);
|
||||
return translationKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a translation key by ID
|
||||
*/
|
||||
getKey(keyId: string): TranslationKey | null {
|
||||
return this.translationKeys.get(keyId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key by key string
|
||||
*/
|
||||
getKeyByString(keyString: string): TranslationKey | null {
|
||||
for (const key of this.translationKeys.values()) {
|
||||
if (key.key === keyString) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all keys with filtering
|
||||
*/
|
||||
listKeys(options?: {
|
||||
namespace?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): { keys: TranslationKey[]; total: number } {
|
||||
let keys = Array.from(this.translationKeys.values());
|
||||
|
||||
// Filter by namespace
|
||||
if (options?.namespace) {
|
||||
keys = keys.filter(k => k.namespace === options.namespace);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (options?.search) {
|
||||
const search = options.search.toLowerCase();
|
||||
keys = keys.filter(k =>
|
||||
k.key.toLowerCase().includes(search) ||
|
||||
k.description?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const page = options?.page || 1;
|
||||
const limit = options?.limit || 20;
|
||||
const start = (page - 1) * limit;
|
||||
const paginatedKeys = keys.slice(start, start + limit);
|
||||
|
||||
return { keys: paginatedKeys, total: keys.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a translation key
|
||||
*/
|
||||
updateKey(keyId: string, updates: Partial<Pick<TranslationKey, 'description' | 'context'>>): TranslationKey | null {
|
||||
const key = this.translationKeys.get(keyId);
|
||||
if (!key) return null;
|
||||
|
||||
if (updates.description !== undefined) key.description = updates.description;
|
||||
if (updates.context !== undefined) key.context = updates.context;
|
||||
key.updatedAt = new Date();
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a translation key
|
||||
*/
|
||||
deleteKey(keyId: string): boolean {
|
||||
const key = this.translationKeys.get(keyId);
|
||||
if (!key) return false;
|
||||
|
||||
// Delete all translation records
|
||||
for (const record of key.translations) {
|
||||
this.translationRecords.delete(record.id);
|
||||
}
|
||||
|
||||
return this.translationKeys.delete(keyId);
|
||||
}
|
||||
|
||||
// ========== TRANSLATION MANAGEMENT ==========
|
||||
|
||||
/**
|
||||
* Add or update translation for a key
|
||||
*/
|
||||
setTranslation(keyId: string, locale: string, value: string, status: TranslationRecord['status'] = 'draft'): TranslationRecord | null {
|
||||
const key = this.translationKeys.get(keyId);
|
||||
if (!key) return null;
|
||||
|
||||
// Find existing record
|
||||
const existingIndex = key.translations.findIndex(t => t.locale === locale);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing
|
||||
const record = key.translations[existingIndex];
|
||||
record.value = value;
|
||||
record.status = status;
|
||||
record.updatedAt = new Date();
|
||||
return record;
|
||||
} else {
|
||||
// Create new
|
||||
const recordId = `record-${keyId}-${locale}`;
|
||||
const record: TranslationRecord = {
|
||||
id: recordId,
|
||||
keyId,
|
||||
locale,
|
||||
value,
|
||||
status,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
key.translations.push(record);
|
||||
this.translationRecords.set(recordId, record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update translations
|
||||
*/
|
||||
bulkSetTranslations(translations: Array<{
|
||||
keyId: string;
|
||||
locale: string;
|
||||
value: string;
|
||||
status?: TranslationRecord['status'];
|
||||
}>): number {
|
||||
let updated = 0;
|
||||
for (const t of translations) {
|
||||
const result = this.setTranslation(t.keyId, t.locale, t.value, t.status);
|
||||
if (result) updated++;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update translation status
|
||||
*/
|
||||
updateTranslationStatus(recordId: string, status: TranslationRecord['status'], reviewedBy?: string): TranslationRecord | null {
|
||||
const record = this.translationRecords.get(recordId);
|
||||
if (!record) return null;
|
||||
|
||||
record.status = status;
|
||||
record.updatedAt = new Date();
|
||||
if (reviewedBy) record.reviewedBy = reviewedBy;
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get missing translations for a locale
|
||||
*/
|
||||
getMissingTranslations(locale: string): TranslationKey[] {
|
||||
const missing: TranslationKey[] = [];
|
||||
|
||||
for (const key of this.translationKeys.values()) {
|
||||
const hasTranslation = key.translations.some(t => t.locale === locale && t.value);
|
||||
if (!hasTranslation) {
|
||||
missing.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
// ========== IMPORT/EXPORT ==========
|
||||
|
||||
/**
|
||||
* Export translations
|
||||
*/
|
||||
exportTranslations(locale: string, options?: {
|
||||
namespace?: string;
|
||||
format?: 'json' | 'csv' | 'xliff' | 'po';
|
||||
includeMetadata?: boolean;
|
||||
}): TranslationExport {
|
||||
const format = options?.format || 'json';
|
||||
let keys = Array.from(this.translationKeys.values());
|
||||
|
||||
if (options?.namespace) {
|
||||
keys = keys.filter(k => k.namespace === options.namespace);
|
||||
}
|
||||
|
||||
const translations: Record<string, string> = {};
|
||||
for (const key of keys) {
|
||||
const record = key.translations.find(t => t.locale === locale);
|
||||
if (record) {
|
||||
translations[key.key] = record.value;
|
||||
}
|
||||
}
|
||||
|
||||
let data: string;
|
||||
switch (format) {
|
||||
case 'json':
|
||||
data = JSON.stringify(translations, null, 2);
|
||||
break;
|
||||
case 'csv':
|
||||
data = this.toCSV(translations);
|
||||
break;
|
||||
case 'xliff':
|
||||
data = this.toXLIFF(translations, locale);
|
||||
break;
|
||||
case 'po':
|
||||
data = this.toPO(translations);
|
||||
break;
|
||||
default:
|
||||
data = JSON.stringify(translations, null, 2);
|
||||
}
|
||||
|
||||
return { locale, namespace: options?.namespace, format, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import translations
|
||||
*/
|
||||
importTranslations(locale: string, data: string, format: 'json' | 'csv'): number {
|
||||
let translations: Record<string, string>;
|
||||
|
||||
if (format === 'json') {
|
||||
translations = JSON.parse(data);
|
||||
} else {
|
||||
translations = this.fromCSV(data);
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
for (const [keyString, value] of Object.entries(translations)) {
|
||||
const key = this.getKeyByString(keyString);
|
||||
if (key) {
|
||||
this.setTranslation(key.id, locale, value, 'draft');
|
||||
imported++;
|
||||
} else {
|
||||
// Create new key
|
||||
const namespace = keyString.split('.')[0];
|
||||
const newKey = this.createKey({ key: keyString, namespace });
|
||||
this.setTranslation(newKey.id, locale, value, 'draft');
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
return imported;
|
||||
}
|
||||
|
||||
// ========== STATISTICS ==========
|
||||
|
||||
/**
|
||||
* Get translation statistics
|
||||
*/
|
||||
getStats(): TranslationStats {
|
||||
const locales = ['en', 'tr', 'es', 'fr', 'de', 'zh', 'pt', 'ar', 'ru', 'ja'];
|
||||
const keys = Array.from(this.translationKeys.values());
|
||||
|
||||
const byNamespace: Record<string, number> = {};
|
||||
for (const key of keys) {
|
||||
byNamespace[key.namespace] = (byNamespace[key.namespace] || 0) + 1;
|
||||
}
|
||||
|
||||
const byLocale: Record<string, { translated: number; pending: number; percentage: number }> = {};
|
||||
for (const locale of locales) {
|
||||
let translated = 0;
|
||||
let pending = 0;
|
||||
|
||||
for (const key of keys) {
|
||||
const record = key.translations.find(t => t.locale === locale);
|
||||
if (record && record.value) {
|
||||
if (record.status === 'published') {
|
||||
translated++;
|
||||
} else {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
byLocale[locale] = {
|
||||
translated,
|
||||
pending,
|
||||
percentage: keys.length > 0 ? Math.round((translated / keys.length) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
totalKeys: keys.length,
|
||||
byNamespace,
|
||||
byLocale,
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// ========== NAMESPACES ==========
|
||||
|
||||
/**
|
||||
* Get all namespaces
|
||||
*/
|
||||
getNamespaces(): string[] {
|
||||
const namespaces = new Set<string>();
|
||||
for (const key of this.translationKeys.values()) {
|
||||
namespaces.add(key.namespace);
|
||||
}
|
||||
return Array.from(namespaces);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename namespace
|
||||
*/
|
||||
renameNamespace(oldName: string, newName: string): number {
|
||||
let renamed = 0;
|
||||
for (const key of this.translationKeys.values()) {
|
||||
if (key.namespace === oldName) {
|
||||
key.namespace = newName;
|
||||
key.key = key.key.replace(`${oldName}.`, `${newName}.`);
|
||||
key.updatedAt = new Date();
|
||||
renamed++;
|
||||
}
|
||||
}
|
||||
return renamed;
|
||||
}
|
||||
|
||||
// ========== HELPER METHODS ==========
|
||||
|
||||
private toCSV(translations: Record<string, string>): string {
|
||||
const lines = ['key,value'];
|
||||
for (const [key, value] of Object.entries(translations)) {
|
||||
const escapedValue = value.replace(/"/g, '""');
|
||||
lines.push(`"${key}","${escapedValue}"`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private fromCSV(data: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
const lines = data.split('\n').slice(1); // Skip header
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^"([^"]+)","(.+)"$/);
|
||||
if (match) {
|
||||
result[match[1]] = match[2].replace(/""/g, '"');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private toXLIFF(translations: Record<string, string>, locale: string): string {
|
||||
let xliff = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<file source-language="en" target-language="${locale}" datatype="plaintext">
|
||||
<body>`;
|
||||
|
||||
for (const [key, value] of Object.entries(translations)) {
|
||||
xliff += `
|
||||
<trans-unit id="${key}">
|
||||
<source>${key}</source>
|
||||
<target>${this.escapeXml(value)}</target>
|
||||
</trans-unit>`;
|
||||
}
|
||||
|
||||
xliff += `
|
||||
</body>
|
||||
</file>
|
||||
</xliff>`;
|
||||
|
||||
return xliff;
|
||||
}
|
||||
|
||||
private toPO(translations: Record<string, string>): string {
|
||||
let po = '';
|
||||
for (const [key, value] of Object.entries(translations)) {
|
||||
po += `
|
||||
msgid "${key}"
|
||||
msgstr "${value.replace(/"/g, '\\"')}"
|
||||
`;
|
||||
}
|
||||
return po.trim();
|
||||
}
|
||||
|
||||
private escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
228
src/modules/i18n/services/translation.service.ts
Normal file
228
src/modules/i18n/services/translation.service.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// Translation Service - Key-based translations
|
||||
// Path: src/modules/i18n/services/translation.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
interface TranslationEntry {
|
||||
key: string;
|
||||
translations: Record<string, string>;
|
||||
context?: string;
|
||||
pluralForms?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TranslationService {
|
||||
private readonly logger = new Logger(TranslationService.name);
|
||||
private readonly translations = new Map<string, TranslationEntry>();
|
||||
private fallbackLocale = 'en';
|
||||
|
||||
constructor() {
|
||||
this.loadDefaultTranslations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load default system translations
|
||||
*/
|
||||
private loadDefaultTranslations(): void {
|
||||
// Common UI translations
|
||||
this.addTranslations('common', {
|
||||
'common.loading': { en: 'Loading...', tr: 'Yükleniyor...', es: 'Cargando...', fr: 'Chargement...', de: 'Laden...', zh: '加载中...', pt: 'Carregando...', ar: 'جار التحميل...', ru: 'Загрузка...', ja: '読み込み中...' },
|
||||
'common.save': { en: 'Save', tr: 'Kaydet', es: 'Guardar', fr: 'Enregistrer', de: 'Speichern', zh: '保存', pt: 'Salvar', ar: 'حفظ', ru: 'Сохранить', ja: '保存' },
|
||||
'common.cancel': { en: 'Cancel', tr: 'İptal', es: 'Cancelar', fr: 'Annuler', de: 'Abbrechen', zh: '取消', pt: 'Cancelar', ar: 'إلغاء', ru: 'Отмена', ja: 'キャンセル' },
|
||||
'common.delete': { en: 'Delete', tr: 'Sil', es: 'Eliminar', fr: 'Supprimer', de: 'Löschen', zh: '删除', pt: 'Excluir', ar: 'حذف', ru: 'Удалить', ja: '削除' },
|
||||
'common.edit': { en: 'Edit', tr: 'Düzenle', es: 'Editar', fr: 'Modifier', de: 'Bearbeiten', zh: '编辑', pt: 'Editar', ar: 'تحرير', ru: 'Редактировать', ja: '編集' },
|
||||
'common.create': { en: 'Create', tr: 'Oluştur', es: 'Crear', fr: 'Créer', de: 'Erstellen', zh: '创建', pt: 'Criar', ar: 'إنشاء', ru: 'Создать', ja: '作成' },
|
||||
'common.search': { en: 'Search', tr: 'Ara', es: 'Buscar', fr: 'Rechercher', de: 'Suchen', zh: '搜索', pt: 'Pesquisar', ar: 'بحث', ru: 'Поиск', ja: '検索' },
|
||||
'common.back': { en: 'Back', tr: 'Geri', es: 'Volver', fr: 'Retour', de: 'Zurück', zh: '返回', pt: 'Voltar', ar: 'رجوع', ru: 'Назад', ja: '戻る' },
|
||||
'common.next': { en: 'Next', tr: 'İleri', es: 'Siguiente', fr: 'Suivant', de: 'Weiter', zh: '下一步', pt: 'Próximo', ar: 'التالي', ru: 'Далее', ja: '次へ' },
|
||||
'common.confirm': { en: 'Confirm', tr: 'Onayla', es: 'Confirmar', fr: 'Confirmer', de: 'Bestätigen', zh: '确认', pt: 'Confirmar', ar: 'تأكيد', ru: 'Подтвердить', ja: '確認' },
|
||||
});
|
||||
|
||||
// Error messages
|
||||
this.addTranslations('errors', {
|
||||
'error.generic': { en: 'Something went wrong', tr: 'Bir şeyler yanlış gitti', es: 'Algo salió mal', fr: 'Une erreur est survenue', de: 'Etwas ist schiefgelaufen', zh: '出了点问题', pt: 'Algo deu errado', ar: 'حدث خطأ ما', ru: 'Что-то пошло не так', ja: '問題が発生しました' },
|
||||
'error.notFound': { en: 'Not found', tr: 'Bulunamadı', es: 'No encontrado', fr: 'Non trouvé', de: 'Nicht gefunden', zh: '未找到', pt: 'Não encontrado', ar: 'غير موجود', ru: 'Не найдено', ja: '見つかりません' },
|
||||
'error.unauthorized': { en: 'Unauthorized', tr: 'Yetkisiz', es: 'No autorizado', fr: 'Non autorisé', de: 'Nicht autorisiert', zh: '未授权', pt: 'Não autorizado', ar: 'غير مصرح', ru: 'Не авторизован', ja: '権限がありません' },
|
||||
'error.forbidden': { en: 'Access denied', tr: 'Erişim engellendi', es: 'Acceso denegado', fr: 'Accès refusé', de: 'Zugriff verweigert', zh: '拒绝访问', pt: 'Acesso negado', ar: 'تم رفض الوصول', ru: 'Доступ запрещен', ja: 'アクセスが拒否されました' },
|
||||
'error.validation': { en: 'Validation error', tr: 'Doğrulama hatası', es: 'Error de validación', fr: 'Erreur de validation', de: 'Validierungsfehler', zh: '验证错误', pt: 'Erro de validação', ar: 'خطأ في التحقق', ru: 'Ошибка валидации', ja: '検証エラー' },
|
||||
});
|
||||
|
||||
// Auth translations
|
||||
this.addTranslations('auth', {
|
||||
'auth.login': { en: 'Login', tr: 'Giriş Yap', es: 'Iniciar sesión', fr: 'Connexion', de: 'Anmelden', zh: '登录', pt: 'Entrar', ar: 'تسجيل الدخول', ru: 'Войти', ja: 'ログイン' },
|
||||
'auth.register': { en: 'Register', tr: 'Kayıt Ol', es: 'Registrarse', fr: "S'inscrire", de: 'Registrieren', zh: '注册', pt: 'Cadastrar', ar: 'التسجيل', ru: 'Регистрация', ja: '登録' },
|
||||
'auth.logout': { en: 'Logout', tr: 'Çıkış Yap', es: 'Cerrar sesión', fr: 'Déconnexion', de: 'Abmelden', zh: '退出', pt: 'Sair', ar: 'تسجيل الخروج', ru: 'Выйти', ja: 'ログアウト' },
|
||||
'auth.forgotPassword': { en: 'Forgot password?', tr: 'Şifreni mi unuttun?', es: '¿Olvidaste tu contraseña?', fr: 'Mot de passe oublié?', de: 'Passwort vergessen?', zh: '忘记密码?', pt: 'Esqueceu a senha?', ar: 'نسيت كلمة المرور؟', ru: 'Забыли пароль?', ja: 'パスワードを忘れましたか?' },
|
||||
});
|
||||
|
||||
// Content translations
|
||||
this.addTranslations('content', {
|
||||
'content.create': { en: 'Create Content', tr: 'İçerik Oluştur', es: 'Crear contenido', fr: 'Créer du contenu', de: 'Inhalt erstellen', zh: '创建内容', pt: 'Criar conteúdo', ar: 'إنشاء محتوى', ru: 'Создать контент', ja: 'コンテンツを作成' },
|
||||
'content.generate': { en: 'Generate', tr: 'Oluştur', es: 'Generar', fr: 'Générer', de: 'Generieren', zh: '生成', pt: 'Gerar', ar: 'توليد', ru: 'Сгенерировать', ja: '生成する' },
|
||||
'content.publish': { en: 'Publish', tr: 'Yayınla', es: 'Publicar', fr: 'Publier', de: 'Veröffentlichen', zh: '发布', pt: 'Publicar', ar: 'نشر', ru: 'Опубликовать', ja: '公開' },
|
||||
'content.schedule': { en: 'Schedule', tr: 'Zamanla', es: 'Programar', fr: 'Planifier', de: 'Planen', zh: '定时', pt: 'Agendar', ar: 'جدولة', ru: 'Запланировать', ja: 'スケジュール' },
|
||||
'content.draft': { en: 'Draft', tr: 'Taslak', es: 'Borrador', fr: 'Brouillon', de: 'Entwurf', zh: '草稿', pt: 'Rascunho', ar: 'مسودة', ru: 'Черновик', ja: '下書き' },
|
||||
});
|
||||
|
||||
// Analytics translations
|
||||
this.addTranslations('analytics', {
|
||||
'analytics.engagement': { en: 'Engagement', tr: 'Etkileşim', es: 'Interacción', fr: 'Engagement', de: 'Engagement', zh: '互动', pt: 'Engajamento', ar: 'التفاعل', ru: 'Вовлечённость', ja: 'エンゲージメント' },
|
||||
'analytics.reach': { en: 'Reach', tr: 'Erişim', es: 'Alcance', fr: 'Portée', de: 'Reichweite', zh: '覆盖', pt: 'Alcance', ar: 'الوصول', ru: 'Охват', ja: 'リーチ' },
|
||||
'analytics.impressions': { en: 'Impressions', tr: 'Gösterim', es: 'Impresiones', fr: 'Impressions', de: 'Impressionen', zh: '展示次数', pt: 'Impressões', ar: 'المشاهدات', ru: 'Показы', ja: 'インプレッション' },
|
||||
'analytics.clicks': { en: 'Clicks', tr: 'Tıklama', es: 'Clics', fr: 'Clics', de: 'Klicks', zh: '点击', pt: 'Cliques', ar: 'النقرات', ru: 'Клики', ja: 'クリック' },
|
||||
});
|
||||
|
||||
// Plural forms
|
||||
this.addPluralTranslations('content.items', {
|
||||
en: { one: '{{count}} item', other: '{{count}} items' },
|
||||
tr: { one: '{{count}} öğe', other: '{{count}} öğe' },
|
||||
es: { one: '{{count}} elemento', other: '{{count}} elementos' },
|
||||
fr: { one: '{{count}} élément', other: '{{count}} éléments' },
|
||||
de: { one: '{{count}} Element', other: '{{count}} Elemente' },
|
||||
zh: { other: '{{count}} 项' },
|
||||
pt: { one: '{{count}} item', other: '{{count}} itens' },
|
||||
ar: { zero: 'لا عناصر', one: 'عنصر واحد', two: 'عنصران', few: '{{count}} عناصر', many: '{{count}} عنصر', other: '{{count}} عنصر' },
|
||||
ru: { one: '{{count}} элемент', few: '{{count}} элемента', many: '{{count}} элементов', other: '{{count}} элементов' },
|
||||
ja: { other: '{{count}} アイテム' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add translations for a namespace
|
||||
*/
|
||||
private addTranslations(namespace: string, entries: Record<string, Record<string, string>>): void {
|
||||
for (const [key, translations] of Object.entries(entries)) {
|
||||
this.translations.set(key, { key, translations });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add plural translations
|
||||
*/
|
||||
private addPluralTranslations(key: string, pluralForms: Record<string, Record<string, string>>): void {
|
||||
this.translations.set(key, { key, translations: {}, pluralForms });
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key
|
||||
*/
|
||||
translate(key: string, locale: string, args?: Record<string, any>): string {
|
||||
const entry = this.translations.get(key);
|
||||
if (!entry) {
|
||||
this.logger.warn(`Missing translation for key: ${key}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
const normalizedLocale = locale.split('-')[0];
|
||||
let translation = entry.translations[normalizedLocale] || entry.translations[this.fallbackLocale] || key;
|
||||
|
||||
// Replace interpolation variables
|
||||
if (args) {
|
||||
for (const [argKey, argValue] of Object.entries(args)) {
|
||||
translation = translation.replace(new RegExp(`{{${argKey}}}`, 'g'), String(argValue));
|
||||
}
|
||||
}
|
||||
|
||||
return translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate with plural support
|
||||
*/
|
||||
translatePlural(key: string, count: number, locale: string, args?: Record<string, any>): string {
|
||||
const entry = this.translations.get(key);
|
||||
if (!entry || !entry.pluralForms) {
|
||||
return this.translate(key, locale, { ...args, count });
|
||||
}
|
||||
|
||||
const normalizedLocale = locale.split('-')[0];
|
||||
const pluralForms = entry.pluralForms[normalizedLocale] || entry.pluralForms[this.fallbackLocale];
|
||||
if (!pluralForms) {
|
||||
return this.translate(key, locale, { ...args, count });
|
||||
}
|
||||
|
||||
const form = this.getPluralForm(count, normalizedLocale);
|
||||
let translation = pluralForms[form] || pluralForms['other'] || key;
|
||||
|
||||
// Replace interpolation variables
|
||||
const allArgs = { ...args, count };
|
||||
for (const [argKey, argValue] of Object.entries(allArgs)) {
|
||||
translation = translation.replace(new RegExp(`{{${argKey}}}`, 'g'), String(argValue));
|
||||
}
|
||||
|
||||
return translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plural form based on locale rules
|
||||
*/
|
||||
private getPluralForm(count: number, locale: string): string {
|
||||
// Arabic has 6 plural forms
|
||||
if (locale === 'ar') {
|
||||
if (count === 0) return 'zero';
|
||||
if (count === 1) return 'one';
|
||||
if (count === 2) return 'two';
|
||||
if (count >= 3 && count <= 10) return 'few';
|
||||
if (count >= 11 && count <= 99) return 'many';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// Russian has 3 plural forms
|
||||
if (locale === 'ru') {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return 'one';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'few';
|
||||
return 'many';
|
||||
}
|
||||
|
||||
// Most languages use simple one/other
|
||||
return count === 1 ? 'one' : 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if translation exists
|
||||
*/
|
||||
hasTranslation(key: string, locale?: string): boolean {
|
||||
const entry = this.translations.get(key);
|
||||
if (!entry) return false;
|
||||
if (!locale) return true;
|
||||
|
||||
const normalizedLocale = locale.split('-')[0];
|
||||
return normalizedLocale in entry.translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all translation keys
|
||||
*/
|
||||
getAllKeys(): string[] {
|
||||
return Array.from(this.translations.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a translation
|
||||
*/
|
||||
setTranslation(key: string, locale: string, value: string): void {
|
||||
const entry = this.translations.get(key) || { key, translations: {} };
|
||||
const normalizedLocale = locale.split('-')[0];
|
||||
entry.translations[normalizedLocale] = value;
|
||||
this.translations.set(key, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all translations for a locale
|
||||
*/
|
||||
getTranslationsForLocale(locale: string): Record<string, string> {
|
||||
const normalizedLocale = locale.split('-')[0];
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [key, entry] of this.translations.entries()) {
|
||||
const translation = entry.translations[normalizedLocale] || entry.translations[this.fallbackLocale];
|
||||
if (translation) {
|
||||
result[key] = translation;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user