Add Russian i18n with DB translations, locale API, and curator review UI.
UI chrome via react-i18next, catalog text in entity_translations with ru.wikipedia seeding, locale-aware search, and Translations page for publish workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f247b418d8
commit
ca58c43648
@@ -0,0 +1,221 @@
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireCurator } = require('../middleware/auth');
|
||||
const { logCuratorAction } = require('../audit-log');
|
||||
const {
|
||||
TRANSLATABLE_FIELDS,
|
||||
getEntityCanonical,
|
||||
listTranslations,
|
||||
upsertTranslation,
|
||||
getTranslationCoverage,
|
||||
} = require('../translation-service');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const VALID_ENTITY_TYPES = new Set(Object.keys(TRANSLATABLE_FIELDS));
|
||||
|
||||
router.get('/worklist/:entityType', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const entityType = req.params.entityType;
|
||||
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
|
||||
if (!VALID_ENTITY_TYPES.has(entityType)) {
|
||||
return res.status(400).json({ error: 'Invalid entity type' });
|
||||
}
|
||||
|
||||
const tables = {
|
||||
artist: { table: 'artists', label: 'name', idCol: 'id' },
|
||||
painting: { table: 'paintings', label: 'title', idCol: 'id' },
|
||||
movement: { table: 'art_movements', label: 'name', idCol: 'id' },
|
||||
};
|
||||
const spec = tables[entityType];
|
||||
if (!spec) {
|
||||
return res.json({ items: [] });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id AS entity_id, ${spec.label} AS label FROM ${spec.table} ORDER BY ${spec.label} LIMIT 1000`,
|
||||
);
|
||||
|
||||
const ids = rows.map((r) => r.entity_id);
|
||||
const { rows: transRows } = ids.length
|
||||
? await pool.query(
|
||||
`SELECT entity_id, field_name, status
|
||||
FROM entity_translations
|
||||
WHERE entity_type = $1 AND entity_id = ANY($2::int[]) AND locale = $3`,
|
||||
[entityType, ids, locale],
|
||||
)
|
||||
: { rows: [] };
|
||||
|
||||
const byEntity = new Map();
|
||||
for (const tr of transRows) {
|
||||
if (!byEntity.has(tr.entity_id)) byEntity.set(tr.entity_id, []);
|
||||
byEntity.get(tr.entity_id).push(tr);
|
||||
}
|
||||
|
||||
const fields = TRANSLATABLE_FIELDS[entityType] || [];
|
||||
const items = rows.map((row) => {
|
||||
const existing = byEntity.get(row.entity_id) || [];
|
||||
const publishedCount = existing.filter((t) => t.status === 'published').length;
|
||||
const draftCount = existing.filter((t) => t.status !== 'published').length;
|
||||
const haveFields = new Set(existing.map((t) => t.field_name));
|
||||
const missingFields = fields.filter((f) => !haveFields.has(f));
|
||||
return {
|
||||
entityId: row.entity_id,
|
||||
label: row.label,
|
||||
publishedCount,
|
||||
draftCount,
|
||||
missingFields,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ items });
|
||||
} catch (err) {
|
||||
console.error('Translation worklist error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to load worklist' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/coverage', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
|
||||
const coverage = await getTranslationCoverage(locale);
|
||||
res.json({ locale, coverage });
|
||||
} catch (err) {
|
||||
console.error('Translation coverage error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to load coverage' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const entityType = typeof req.query.entityType === 'string' ? req.query.entityType : undefined;
|
||||
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
|
||||
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
|
||||
const rows = await listTranslations({ entityType, locale, status });
|
||||
res.json({ translations: rows });
|
||||
} catch (err) {
|
||||
console.error('List translations error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to list translations' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:entityType/:id', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const entityType = req.params.entityType;
|
||||
const entityId = parseInt(req.params.id, 10);
|
||||
if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isFinite(entityId)) {
|
||||
return res.status(400).json({ error: 'Invalid entity type or id' });
|
||||
}
|
||||
|
||||
const canonical = await getEntityCanonical(entityType, entityId);
|
||||
if (!canonical) {
|
||||
return res.status(404).json({ error: 'Entity not found' });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT locale, field_name, value, status, source, updated_at
|
||||
FROM entity_translations
|
||||
WHERE entity_type = $1 AND entity_id = $2
|
||||
ORDER BY locale, field_name`,
|
||||
[entityType, entityId],
|
||||
);
|
||||
|
||||
res.json({
|
||||
entityType,
|
||||
entityId,
|
||||
canonical,
|
||||
translatableFields: TRANSLATABLE_FIELDS[entityType],
|
||||
translations: rows,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Get translation error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to load translation' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:entityType/:id', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const entityType = req.params.entityType;
|
||||
const entityId = parseInt(req.params.id, 10);
|
||||
const locale = typeof req.body?.locale === 'string' ? req.body.locale : 'ru';
|
||||
const fields = req.body?.fields;
|
||||
const status = typeof req.body?.status === 'string' ? req.body.status : 'draft';
|
||||
|
||||
if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isFinite(entityId)) {
|
||||
return res.status(400).json({ error: 'Invalid entity type or id' });
|
||||
}
|
||||
if (!fields || typeof fields !== 'object') {
|
||||
return res.status(400).json({ error: 'fields object required' });
|
||||
}
|
||||
|
||||
const canonical = await getEntityCanonical(entityType, entityId);
|
||||
if (!canonical) {
|
||||
return res.status(404).json({ error: 'Entity not found' });
|
||||
}
|
||||
|
||||
const allowed = new Set(TRANSLATABLE_FIELDS[entityType]);
|
||||
const saved = [];
|
||||
for (const [fieldName, value] of Object.entries(fields)) {
|
||||
if (!allowed.has(fieldName) || typeof value !== 'string') continue;
|
||||
const row = await upsertTranslation({
|
||||
entityType,
|
||||
entityId,
|
||||
locale,
|
||||
fieldName,
|
||||
value,
|
||||
status,
|
||||
source: 'manual',
|
||||
});
|
||||
saved.push(row);
|
||||
}
|
||||
|
||||
await logCuratorAction({
|
||||
userId: req.curatorUser.id,
|
||||
action: 'translation.upsert',
|
||||
resourceType: entityType,
|
||||
resourceId: entityId,
|
||||
details: { locale, fieldCount: saved.length, status },
|
||||
req,
|
||||
});
|
||||
|
||||
res.json({ saved });
|
||||
} catch (err) {
|
||||
console.error('Upsert translation error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to save translation' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:entityType/:id/publish', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const entityType = req.params.entityType;
|
||||
const entityId = parseInt(req.params.id, 10);
|
||||
const locale = typeof req.body?.locale === 'string' ? req.body.locale : 'ru';
|
||||
|
||||
if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isFinite(entityId)) {
|
||||
return res.status(400).json({ error: 'Invalid entity type or id' });
|
||||
}
|
||||
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE entity_translations
|
||||
SET status = 'published', updated_at = now()
|
||||
WHERE entity_type = $1 AND entity_id = $2 AND locale = $3 AND status IN ('draft', 'reviewed')`,
|
||||
[entityType, entityId, locale],
|
||||
);
|
||||
|
||||
await logCuratorAction({
|
||||
userId: req.curatorUser.id,
|
||||
action: 'translation.publish',
|
||||
resourceType: entityType,
|
||||
resourceId: entityId,
|
||||
details: { locale, updated: rowCount },
|
||||
req,
|
||||
});
|
||||
|
||||
res.json({ published: rowCount });
|
||||
} catch (err) {
|
||||
console.error('Publish translation error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to publish translations' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user