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,340 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user