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>
341 lines
11 KiB
JavaScript
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,
|
|
};
|