Files
Art-gallery/server/translation-service.js
Danila KhodjaefandCursor ca58c43648 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>
2026-07-15 11:51:46 +03:00

341 lines
11 KiB
JavaScript

const pool = require('./db');
const SUPPORTED_LOCALES = new Set(['en', 'ru']);
const DEFAULT_LOCALE = 'en';
const PUBLIC_STATUSES = ['published'];
const CURATOR_STATUSES = ['draft', 'reviewed', 'published'];
const TRANSLATABLE_FIELDS = {
era: ['name', 'description'],
movement: ['name', 'description'],
artist: ['name', 'bio_short', 'bio_full'],
artist_period: ['name', 'description'],
painting: ['title', 'description'],
annotation: ['label', 'body'],
influence_source: ['notes', 'aspects', 'quote', 'period_note'],
};
function resolveLocale(req) {
const q = req?.query?.locale;
if (typeof q === 'string' && SUPPORTED_LOCALES.has(q.toLowerCase())) {
return q.toLowerCase();
}
const accept = req?.headers?.['accept-language'];
if (typeof accept === 'string' && /\bru\b/i.test(accept.split(',')[0])) {
return 'ru';
}
return DEFAULT_LOCALE;
}
function translationStatuses(req) {
if (req?.curatorUser && req?.query?.preview === '1') {
return CURATOR_STATUSES;
}
return PUBLIC_STATUSES;
}
function translationKey(entityId, fieldName) {
return `${entityId}:${fieldName}`;
}
async function loadTranslationsByType(entityType, ids, locale, fields, statuses) {
const map = new Map();
if (locale === DEFAULT_LOCALE || !ids.length || !fields.length) return map;
const uniqueIds = [...new Set(ids.filter((id) => Number.isFinite(id)))];
if (!uniqueIds.length) return map;
const { rows } = await pool.query(
`SELECT entity_id, field_name, value, status
FROM entity_translations
WHERE entity_type = $1
AND entity_id = ANY($2::int[])
AND locale = $3
AND field_name = ANY($4::text[])
AND status = ANY($5::text[])`,
[entityType, uniqueIds, locale, fields, statuses],
);
for (const row of rows) {
map.set(translationKey(row.entity_id, row.field_name), row.value);
}
return map;
}
function applyFields(row, entityType, entityId, fields, map, target = row) {
for (const field of fields) {
const value = map.get(translationKey(entityId, field));
if (value != null && value !== '') {
target[field] = value;
}
}
return target;
}
async function localizeEras(eras, locale, statuses) {
if (locale === DEFAULT_LOCALE || !eras.length) return eras;
const map = await loadTranslationsByType(
'era',
eras.map((e) => e.id),
locale,
TRANSLATABLE_FIELDS.era,
statuses,
);
return eras.map((era) => applyFields({ ...era }, 'era', era.id, TRANSLATABLE_FIELDS.era, map));
}
async function localizeMovements(movements, locale, statuses, eraNameById = new Map()) {
if (locale === DEFAULT_LOCALE || !movements.length) return movements;
const map = await loadTranslationsByType(
'movement',
movements.map((m) => m.id),
locale,
TRANSLATABLE_FIELDS.movement,
statuses,
);
const eraIds = movements.map((m) => m.era_id).filter(Boolean);
const eraMap = eraIds.length
? await loadTranslationsByType('era', eraIds, locale, ['name'], statuses)
: new Map();
return movements.map((movement) => {
const out = applyFields({ ...movement }, 'movement', movement.id, TRANSLATABLE_FIELDS.movement, map);
if (movement.era_id && eraMap.has(translationKey(movement.era_id, 'name'))) {
out.era_name = eraMap.get(translationKey(movement.era_id, 'name'));
} else if (movement.era_id && eraNameById.has(movement.era_id)) {
out.era_name = eraNameById.get(movement.era_id);
}
return out;
});
}
async function localizeArtists(artists, locale, statuses) {
if (locale === DEFAULT_LOCALE || !artists.length) return artists;
const artistMap = await loadTranslationsByType(
'artist',
artists.map((a) => a.id),
locale,
TRANSLATABLE_FIELDS.artist,
statuses,
);
const movementIds = artists.map((a) => a.movement_id).filter(Boolean);
const movementMap = movementIds.length
? await loadTranslationsByType('movement', movementIds, locale, ['name'], statuses)
: new Map();
return artists.map((artist) => {
const out = applyFields({ ...artist }, 'artist', artist.id, TRANSLATABLE_FIELDS.artist, artistMap);
if (artist.movement_id && movementMap.has(translationKey(artist.movement_id, 'name'))) {
out.movement_name = movementMap.get(translationKey(artist.movement_id, 'name'));
}
return out;
});
}
async function localizePeriods(periods, locale, statuses) {
if (locale === DEFAULT_LOCALE || !periods.length) return periods;
const map = await loadTranslationsByType(
'artist_period',
periods.map((p) => p.id),
locale,
TRANSLATABLE_FIELDS.artist_period,
statuses,
);
return periods.map((period) =>
applyFields({ ...period }, 'artist_period', period.id, TRANSLATABLE_FIELDS.artist_period, map),
);
}
async function localizePaintings(paintings, locale, statuses) {
if (locale === DEFAULT_LOCALE || !paintings.length) return paintings;
const paintingMap = await loadTranslationsByType(
'painting',
paintings.map((p) => p.id),
locale,
TRANSLATABLE_FIELDS.painting,
statuses,
);
const artistIds = paintings.map((p) => p.artist_id).filter(Boolean);
const artistMap = artistIds.length
? await loadTranslationsByType('artist', artistIds, locale, ['name'], statuses)
: new Map();
return paintings.map((painting) => {
const out = applyFields({ ...painting }, 'painting', painting.id, TRANSLATABLE_FIELDS.painting, paintingMap);
const artistId = painting.artist_id;
if (artistId && artistMap.has(translationKey(artistId, 'name'))) {
out.artist_name = artistMap.get(translationKey(artistId, 'name'));
}
return out;
});
}
async function localizeAnnotations(annotations, locale, statuses) {
if (locale === DEFAULT_LOCALE || !annotations.length) return annotations;
const map = await loadTranslationsByType(
'annotation',
annotations.map((a) => a.id),
locale,
TRANSLATABLE_FIELDS.annotation,
statuses,
);
return annotations.map((ann) =>
applyFields({ ...ann }, 'annotation', ann.id, TRANSLATABLE_FIELDS.annotation, map),
);
}
async function localizeInfluenceSources(rows, locale, statuses) {
if (locale === DEFAULT_LOCALE || !rows.length) return rows;
const sourceIds = rows.map((r) => r.influence_source_id || r.id).filter(Boolean);
const map = await loadTranslationsByType(
'influence_source',
sourceIds,
locale,
TRANSLATABLE_FIELDS.influence_source,
statuses,
);
const paintingIds = rows.map((r) => r.id).filter(Boolean);
const artistIds = rows.map((r) => r.artist_id || r.source_artist_id).filter(Boolean);
const movementIds = rows.map((r) => r.movement_id).filter(Boolean);
const [paintingMap, artistMap, movementMap] = await Promise.all([
paintingIds.length
? loadTranslationsByType('painting', paintingIds, locale, ['title'], statuses)
: Promise.resolve(new Map()),
artistIds.length
? loadTranslationsByType('artist', artistIds, locale, ['name'], statuses)
: Promise.resolve(new Map()),
movementIds.length
? loadTranslationsByType('movement', movementIds, locale, ['name'], statuses)
: Promise.resolve(new Map()),
]);
return rows.map((row) => {
const sourceId = row.influence_source_id || row.id;
const out = applyFields(
{ ...row },
'influence_source',
sourceId,
TRANSLATABLE_FIELDS.influence_source,
map,
);
if (row.id && paintingMap.has(translationKey(row.id, 'title'))) {
out.title = paintingMap.get(translationKey(row.id, 'title'));
}
const artistId = row.artist_id || row.source_artist_id;
if (artistId && artistMap.has(translationKey(artistId, 'name'))) {
if (row.source_artist_name != null) out.source_artist_name = artistMap.get(translationKey(artistId, 'name'));
if (row.artist_name != null) out.artist_name = artistMap.get(translationKey(artistId, 'name'));
}
if (row.movement_id && movementMap.has(translationKey(row.movement_id, 'name'))) {
out.movement_name = movementMap.get(translationKey(row.movement_id, 'name'));
}
return out;
});
}
async function upsertTranslation({
entityType,
entityId,
locale,
fieldName,
value,
status = 'draft',
source = 'manual',
}) {
const { rows } = await pool.query(
`INSERT INTO entity_translations (entity_type, entity_id, locale, field_name, value, status, source)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (entity_type, entity_id, locale, field_name)
DO UPDATE SET value = EXCLUDED.value, status = EXCLUDED.status, source = EXCLUDED.source, updated_at = now()
RETURNING *`,
[entityType, entityId, locale, fieldName, value, status, source],
);
return rows[0];
}
async function getEntityCanonical(entityType, entityId) {
const tables = {
era: { table: 'historical_eras', fields: ['name', 'description'] },
movement: { table: 'art_movements', fields: ['name', 'description'] },
artist: { table: 'artists', fields: ['name', 'bio_short', 'bio_full'] },
artist_period: { table: 'artist_periods', fields: ['name', 'description'] },
painting: { table: 'paintings', fields: ['title', 'description'] },
annotation: { table: 'painting_annotations', fields: ['label', 'body'] },
influence_source: {
table: 'painting_influence_sources',
fields: ['notes', 'aspects', 'quote', 'period_note'],
},
};
const spec = tables[entityType];
if (!spec) return null;
const cols = ['id', ...spec.fields].join(', ');
const { rows } = await pool.query(
`SELECT ${cols} FROM ${spec.table} WHERE id = $1`,
[entityId],
);
return rows[0] || null;
}
async function listTranslations(filters = {}) {
const conditions = [];
const params = [];
if (filters.entityType) {
params.push(filters.entityType);
conditions.push(`entity_type = $${params.length}`);
}
if (filters.locale) {
params.push(filters.locale);
conditions.push(`locale = $${params.length}`);
}
if (filters.status) {
params.push(filters.status);
conditions.push(`status = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const { rows } = await pool.query(
`SELECT * FROM entity_translations ${where} ORDER BY entity_type, entity_id, field_name LIMIT 5000`,
params,
);
return rows;
}
async function getTranslationCoverage(locale = 'ru') {
const { rows } = await pool.query(
`SELECT
(SELECT COUNT(*)::int FROM artists) AS artists_total,
(SELECT COUNT(DISTINCT entity_id)::int FROM entity_translations
WHERE entity_type = 'artist' AND locale = $1 AND field_name = 'bio_full' AND status = 'published') AS artists_bio_full,
(SELECT COUNT(*)::int FROM paintings) AS paintings_total,
(SELECT COUNT(DISTINCT entity_id)::int FROM entity_translations
WHERE entity_type = 'painting' AND locale = $1 AND field_name = 'title' AND status = 'published') AS paintings_title,
(SELECT COUNT(*)::int FROM entity_translations WHERE locale = $1 AND status = 'draft') AS draft_count,
(SELECT COUNT(*)::int FROM entity_translations WHERE locale = $1 AND status = 'published') AS published_count`,
[locale],
);
return rows[0];
}
module.exports = {
SUPPORTED_LOCALES,
DEFAULT_LOCALE,
TRANSLATABLE_FIELDS,
PUBLIC_STATUSES,
CURATOR_STATUSES,
resolveLocale,
translationStatuses,
loadTranslationsByType,
localizeEras,
localizeMovements,
localizeArtists,
localizePeriods,
localizePaintings,
localizeAnnotations,
localizeInfluenceSources,
upsertTranslation,
getEntityCanonical,
listTranslations,
getTranslationCoverage,
};