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, };