- Add i18n module with tForUser/tForLang/t functions and {{param}} interpolation
- Add 3 locale files: en.json, es.json, de.json (201 keys each)
- Add language selection on /start and /language command with flag emojis
- Localize all bot user-facing strings (handlers, keyboards, errors)
- Localize messageRouter keyboard matching via locale keys
- Add DB migrations 008 (language column) and 009 (language_set column)
- Add localization admin tab at /locales for editing translations
- Add userService.getUserLanguage/setUserLanguage methods
- Cache user object on msg.__user to avoid triple DB fetch
- Idempotent migrations with checkColumnExists guards
- Error boundary on i18n locale file loading
- Admin locales route uses AVAILABLE_LANGUAGES import
172 lines
5.8 KiB
JavaScript
172 lines
5.8 KiB
JavaScript
import express, { Router } from 'express';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import logger from '../../utils/logger.js';
|
|
import { layout } from '../views/layout.js';
|
|
import { AVAILABLE_LANGUAGES } from '../../i18n/index.js';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const LOCALES_DIR = path.join(__dirname, '..', '..', 'i18n', 'locales');
|
|
|
|
const router = Router();
|
|
|
|
router.get('/', (req, res) => {
|
|
try {
|
|
const files = fs.readdirSync(LOCALES_DIR).filter(f => f.endsWith('.json'));
|
|
const locales = {};
|
|
|
|
for (const file of files) {
|
|
const lang = file.replace('.json', '');
|
|
const content = fs.readFileSync(path.join(LOCALES_DIR, file), 'utf-8');
|
|
locales[lang] = JSON.parse(content);
|
|
}
|
|
|
|
const html = renderLocalesPage(locales);
|
|
res.send(html);
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'Error loading locales');
|
|
res.status(500).send('Error loading locales');
|
|
}
|
|
});
|
|
|
|
router.post('/save', express.json(), (req, res) => {
|
|
try {
|
|
const { lang, key, value } = req.body;
|
|
|
|
if (!lang || !key || value === undefined) {
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
|
|
if (!AVAILABLE_LANGUAGES.includes(lang)) {
|
|
return res.status(400).json({ error: 'Invalid language' });
|
|
}
|
|
|
|
const filePath = path.join(LOCALES_DIR, `${lang}.json`);
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const locale = JSON.parse(content);
|
|
|
|
const keys = key.split('.');
|
|
let obj = locale;
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
if (!obj[keys[i]]) obj[keys[i]] = {};
|
|
obj = obj[keys[i]];
|
|
}
|
|
obj[keys[keys.length - 1]] = value;
|
|
|
|
fs.writeFileSync(filePath, JSON.stringify(locale, null, 2) + '\n', 'utf-8');
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'Error saving locale');
|
|
res.status(500).json({ error: 'Error saving locale' });
|
|
}
|
|
});
|
|
|
|
function renderLocalesPage(locales) {
|
|
const sections = Object.keys(locales.en || {});
|
|
|
|
let sectionsHtml = '';
|
|
for (const section of sections) {
|
|
const keys = Object.keys(locales.en[section] || {});
|
|
let rowsHtml = '';
|
|
for (const key of keys) {
|
|
const fullKey = `${section}.${key}`;
|
|
const enVal = locales.en?.[section]?.[key] || '';
|
|
const deVal = locales.de?.[section]?.[key] || '';
|
|
const esVal = locales.es?.[section]?.[key] || '';
|
|
|
|
rowsHtml += `
|
|
<tr data-key="${fullKey}">
|
|
<td class="key-cell"><code>${fullKey}</code></td>
|
|
<td><input type="text" data-lang="en" data-key="${fullKey}" value="${escapeAttr(enVal)}" class="locale-input"></td>
|
|
<td><input type="text" data-lang="de" data-key="${fullKey}" value="${escapeAttr(deVal)}" class="locale-input"></td>
|
|
<td><input type="text" data-lang="es" data-key="${fullKey}" value="${escapeAttr(esVal)}" class="locale-input"></td>
|
|
</tr>`;
|
|
}
|
|
|
|
sectionsHtml += `
|
|
<div class="locale-section">
|
|
<h3>${section}</h3>
|
|
<table class="locale-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Ключ</th>
|
|
<th>English</th>
|
|
<th>Deutsch</th>
|
|
<th>Español</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rowsHtml}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
const content = `
|
|
<div class="locale-actions">
|
|
<button id="saveAllBtn" class="btn btn-primary">💾 Сохранить все изменения</button>
|
|
<span id="saveStatus" class="save-status"></span>
|
|
</div>
|
|
${sectionsHtml}
|
|
<script>
|
|
document.getElementById('saveAllBtn').addEventListener('click', async () => {
|
|
const btn = document.getElementById('saveAllBtn');
|
|
const status = document.getElementById('saveStatus');
|
|
btn.disabled = true;
|
|
status.textContent = 'Сохранение...';
|
|
|
|
const inputs = document.querySelectorAll('.locale-input');
|
|
const changes = [];
|
|
|
|
for (const input of inputs) {
|
|
if (input.dataset.originalValue !== input.value) {
|
|
changes.push({
|
|
lang: input.dataset.lang,
|
|
key: input.dataset.key,
|
|
value: input.value
|
|
});
|
|
}
|
|
}
|
|
|
|
let saved = 0;
|
|
let errors = 0;
|
|
|
|
for (const change of changes) {
|
|
try {
|
|
const res = await fetch('/locales/save', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(change)
|
|
});
|
|
if (res.ok) saved++;
|
|
else errors++;
|
|
} catch (e) {
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
status.textContent = errors > 0
|
|
? 'Сохранено ' + saved + ' из ' + changes.length + '. Ошибок: ' + errors
|
|
: 'Сохранено ' + saved + ' из ' + changes.length + ' изменений ✓';
|
|
btn.disabled = false;
|
|
|
|
for (const input of inputs) {
|
|
input.dataset.originalValue = input.value;
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('.locale-input').forEach(input => {
|
|
input.dataset.originalValue = input.value;
|
|
});
|
|
</script>
|
|
`;
|
|
|
|
return layout('Локализация', content, 'locales');
|
|
}
|
|
|
|
function escapeAttr(str) {
|
|
return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
export default router;
|