309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
const I18nPreferencesRepository = require('../../repositories/settings/I18nPreferencesRepository');
|
|
const { logger } = require('../../middleware/logger');
|
|
|
|
const repo = new I18nPreferencesRepository();
|
|
|
|
class I18nPreferencesController {
|
|
static _normalizeLanguageCode(value) {
|
|
const raw = String(value == null ? '' : value).trim();
|
|
if (!raw) return '';
|
|
|
|
const normalized = raw.replace('_', '-');
|
|
const pattern = /^[a-z]{2,3}(-[a-z0-9]{2,8})?$/i;
|
|
if (!pattern.test(normalized)) return null;
|
|
return normalized.toLowerCase();
|
|
}
|
|
|
|
static _normalizeStringArray(values) {
|
|
if (!Array.isArray(values)) return [];
|
|
const normalized = values
|
|
.map((v) => (v == null ? '' : String(v).trim()))
|
|
.filter(Boolean);
|
|
return [...new Set(normalized)];
|
|
}
|
|
|
|
static _normalizeCategories(categories) {
|
|
if (!Array.isArray(categories)) return [];
|
|
|
|
return categories
|
|
.map((item, idx) => {
|
|
const id = String(item?.id ?? '').trim() || `category_${idx + 1}`;
|
|
const label = String(item?.label ?? id).trim() || id;
|
|
const namespaces = I18nPreferencesController._normalizeStringArray(item?.namespaces);
|
|
const isCustom = Boolean(item?.isCustom);
|
|
return { id, label, namespaces, isCustom };
|
|
});
|
|
}
|
|
|
|
static _buildResponse(preferences) {
|
|
return {
|
|
ok: true,
|
|
preferences,
|
|
categories: preferences.categories,
|
|
globalKeys: preferences.globalKeys,
|
|
};
|
|
}
|
|
|
|
static _normalizeLanguagePayload(body) {
|
|
const source = (body?.language && typeof body.language === 'object') ? body.language : body;
|
|
|
|
const rawLanguageCode = source?.languageCode ?? source?.code ?? source?.lang;
|
|
const hasAnyLanguageField = rawLanguageCode !== undefined
|
|
|| source?.label !== undefined
|
|
|| source?.name !== undefined
|
|
|| source?.isEnabled !== undefined
|
|
|| source?.isCustom !== undefined;
|
|
|
|
if (!hasAnyLanguageField) return null;
|
|
|
|
const languageCode = I18nPreferencesController._normalizeLanguageCode(rawLanguageCode);
|
|
if (languageCode === null || !languageCode) {
|
|
throw new Error('Invalid language payload: languageCode is required');
|
|
}
|
|
|
|
const label = String(source?.label ?? source?.name ?? languageCode.toUpperCase()).trim();
|
|
const toBool = (v, fallback) => {
|
|
if (v === undefined || v === null) return fallback;
|
|
if (typeof v === 'boolean') return v;
|
|
const n = String(v).trim().toLowerCase();
|
|
if (['1', 'true', 'yes', 'on'].includes(n)) return true;
|
|
if (['0', 'false', 'no', 'off'].includes(n)) return false;
|
|
return fallback;
|
|
};
|
|
|
|
return {
|
|
languageCode,
|
|
label,
|
|
isEnabled: toBool(source?.isEnabled, true),
|
|
isCustom: toBool(source?.isCustom, true),
|
|
};
|
|
}
|
|
|
|
static _normalizeTranslationsPayload(body, fallbackLanguageCode) {
|
|
const raw = body?.translations ?? body?.translationOverrides ?? body?.entries;
|
|
if (raw == null) return [];
|
|
|
|
const normalized = [];
|
|
|
|
if (Array.isArray(raw)) {
|
|
for (const item of raw) {
|
|
const languageCode = I18nPreferencesController._normalizeLanguageCode(
|
|
item?.languageCode ?? item?.lang ?? fallbackLanguageCode
|
|
);
|
|
const namespace = String(item?.namespace ?? '').trim();
|
|
const key = String(item?.key ?? item?.t_key ?? '').trim();
|
|
const value = item?.value ?? item?.t_value;
|
|
if (!languageCode || !namespace || !key || value === undefined) continue;
|
|
normalized.push({
|
|
languageCode,
|
|
namespace,
|
|
key,
|
|
value: String(value),
|
|
isCustom: item?.isCustom,
|
|
});
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
if (typeof raw === 'object') {
|
|
for (const [namespace, kv] of Object.entries(raw)) {
|
|
if (!kv || typeof kv !== 'object') continue;
|
|
for (const [key, value] of Object.entries(kv)) {
|
|
const languageCode = I18nPreferencesController._normalizeLanguageCode(fallbackLanguageCode);
|
|
if (!languageCode || !namespace || !key || value === undefined) continue;
|
|
normalized.push({
|
|
languageCode,
|
|
namespace: String(namespace).trim(),
|
|
key: String(key).trim(),
|
|
value: String(value),
|
|
isCustom: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
static _extractPreferencesPayload(body) {
|
|
const source = (body?.preferences && typeof body.preferences === 'object')
|
|
? body.preferences
|
|
: body;
|
|
|
|
const hasCategories = Object.prototype.hasOwnProperty.call(source || {}, 'categories');
|
|
const hasGlobalKeys = Object.prototype.hasOwnProperty.call(source || {}, 'globalKeys');
|
|
|
|
return {
|
|
categories: hasCategories ? I18nPreferencesController._normalizeCategories(source?.categories) : undefined,
|
|
globalKeys: hasGlobalKeys ? I18nPreferencesController._normalizeStringArray(source?.globalKeys) : undefined,
|
|
};
|
|
}
|
|
|
|
static async _upsertBundle(req, res) {
|
|
try {
|
|
const userId = req.user?.userId ?? req.user?.id ?? null;
|
|
const language = I18nPreferencesController._normalizeLanguagePayload(req.body || {});
|
|
const fallbackLanguageCode = language?.languageCode
|
|
|| I18nPreferencesController._normalizeLanguageCode(req.body?.languageCode ?? req.body?.lang);
|
|
const translations = I18nPreferencesController._normalizeTranslationsPayload(req.body || {}, fallbackLanguageCode);
|
|
const extracted = I18nPreferencesController._extractPreferencesPayload(req.body || {});
|
|
|
|
const result = await repo.upsertBundle({
|
|
categories: extracted.categories,
|
|
globalKeys: extracted.globalKeys,
|
|
language,
|
|
translations,
|
|
updatedByUserId: userId,
|
|
});
|
|
|
|
return res.status(200).json({
|
|
...I18nPreferencesController._buildResponse(result.preferences),
|
|
language: result.language || null,
|
|
translationsUpserted: result.translationsUpserted || 0,
|
|
});
|
|
} catch (error) {
|
|
if (String(error?.message || '').includes('Invalid language payload')) {
|
|
return res.status(400).json({ ok: false, message: error.message });
|
|
}
|
|
logger.error('i18nPreferences:upsertBundle:failed', { error: error?.message });
|
|
return res.status(500).json({ ok: false, message: 'Failed to upsert i18n language data' });
|
|
}
|
|
}
|
|
|
|
static async get(req, res) {
|
|
try {
|
|
const preferences = await repo.get();
|
|
return res.status(200).json(I18nPreferencesController._buildResponse(preferences));
|
|
} catch (error) {
|
|
logger.error('i18nPreferences:get:failed', { error: error?.message });
|
|
return res.status(500).json({ ok: false, message: 'Failed to load i18n preferences' });
|
|
}
|
|
}
|
|
|
|
static async post(req, res) {
|
|
return I18nPreferencesController._upsertBundle(req, res);
|
|
}
|
|
|
|
static async put(req, res) {
|
|
return I18nPreferencesController._upsertBundle(req, res);
|
|
}
|
|
|
|
static async delete(req, res) {
|
|
try {
|
|
const requestedLanguageCode = I18nPreferencesController._normalizeLanguageCode(req.query?.languageCode);
|
|
if (requestedLanguageCode === null) {
|
|
return res.status(400).json({ ok: false, message: 'Invalid languageCode query parameter' });
|
|
}
|
|
|
|
if (requestedLanguageCode) {
|
|
const result = await repo.deleteLanguageEntries(
|
|
requestedLanguageCode,
|
|
req.user?.userId ?? req.user?.id ?? null
|
|
);
|
|
|
|
return res.status(200).json({
|
|
ok: true,
|
|
languageCode: requestedLanguageCode,
|
|
deletedRows: result.deletedRows,
|
|
touchedTables: result.touchedTables,
|
|
});
|
|
}
|
|
|
|
const hasBodyReplacement = req.body && (
|
|
Object.prototype.hasOwnProperty.call(req.body, 'categories') ||
|
|
Object.prototype.hasOwnProperty.call(req.body, 'globalKeys')
|
|
);
|
|
|
|
const preferences = hasBodyReplacement
|
|
? await repo.upsert({
|
|
categories: I18nPreferencesController._normalizeCategories(req.body?.categories),
|
|
globalKeys: I18nPreferencesController._normalizeStringArray(req.body?.globalKeys),
|
|
updatedByUserId: req.user?.userId ?? req.user?.id ?? null,
|
|
})
|
|
: await repo.clear(req.user?.userId ?? req.user?.id ?? null);
|
|
|
|
return res.status(200).json(I18nPreferencesController._buildResponse(preferences));
|
|
} catch (error) {
|
|
logger.error('i18nPreferences:delete:failed', { error: error?.message });
|
|
return res.status(500).json({ ok: false, message: 'Failed to delete i18n preferences' });
|
|
}
|
|
}
|
|
|
|
static async getTranslations(req, res) {
|
|
try {
|
|
const languageCodeRaw = req.query?.languageCode ?? req.query?.lang;
|
|
const normalizedLanguageCode = languageCodeRaw
|
|
? I18nPreferencesController._normalizeLanguageCode(languageCodeRaw)
|
|
: '';
|
|
if (normalizedLanguageCode === null) {
|
|
return res.status(400).json({ ok: false, message: 'Invalid languageCode query parameter' });
|
|
}
|
|
|
|
const namespace = String(req.query?.namespace ?? '').trim() || undefined;
|
|
const translations = await repo.listTranslations({
|
|
languageCode: normalizedLanguageCode || undefined,
|
|
namespace,
|
|
});
|
|
|
|
return res.status(200).json({ ok: true, translations });
|
|
} catch (error) {
|
|
logger.error('i18nTranslations:get:failed', { error: error?.message });
|
|
return res.status(500).json({ ok: false, message: 'Failed to load translations' });
|
|
}
|
|
}
|
|
|
|
static async upsertTranslations(req, res) {
|
|
try {
|
|
const language = I18nPreferencesController._normalizeLanguagePayload(req.body || {});
|
|
const fallbackLanguageCode = language?.languageCode
|
|
|| I18nPreferencesController._normalizeLanguageCode(req.body?.languageCode ?? req.body?.lang);
|
|
const translations = I18nPreferencesController._normalizeTranslationsPayload(req.body || {}, fallbackLanguageCode);
|
|
|
|
if (!translations.length) {
|
|
return res.status(400).json({ ok: false, message: 'No valid translations provided' });
|
|
}
|
|
|
|
const userId = req.user?.userId ?? req.user?.id ?? null;
|
|
const result = await repo.upsertTranslations({ translations, updatedByUserId: userId });
|
|
|
|
if (language) {
|
|
await repo.upsertBundle({ language, updatedByUserId: userId });
|
|
}
|
|
|
|
return res.status(200).json({
|
|
ok: true,
|
|
translationsUpserted: result.upsertedCount || 0,
|
|
});
|
|
} catch (error) {
|
|
if (String(error?.message || '').includes('Invalid language payload')) {
|
|
return res.status(400).json({ ok: false, message: error.message });
|
|
}
|
|
logger.error('i18nTranslations:upsert:failed', { error: error?.message });
|
|
return res.status(500).json({ ok: false, message: 'Failed to upsert translations' });
|
|
}
|
|
}
|
|
|
|
static async scan(req, res) {
|
|
try {
|
|
const languageCodeRaw = req.query?.languageCode ?? req.query?.lang ?? req.body?.languageCode ?? req.body?.lang;
|
|
const normalizedLanguageCode = languageCodeRaw
|
|
? I18nPreferencesController._normalizeLanguageCode(languageCodeRaw)
|
|
: '';
|
|
if (normalizedLanguageCode === null) {
|
|
return res.status(400).json({ ok: false, message: 'Invalid languageCode parameter' });
|
|
}
|
|
|
|
const summary = await repo.getScanSummary({ languageCode: normalizedLanguageCode || undefined });
|
|
return res.status(200).json({
|
|
ok: true,
|
|
...summary,
|
|
});
|
|
} catch (error) {
|
|
logger.error('i18nScan:failed', { error: error?.message });
|
|
return res.status(500).json({ ok: false, message: 'Failed to scan i18n data' });
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = I18nPreferencesController;
|