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
+86
-16
@@ -14,6 +14,18 @@ const authRoutes = require('./routes/auth');
|
||||
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
|
||||
const { getVersionInfo } = require('./version-info');
|
||||
const { searchCatalog } = require('./search-service');
|
||||
const {
|
||||
resolveLocale,
|
||||
translationStatuses,
|
||||
localizeEras,
|
||||
localizeMovements,
|
||||
localizeArtists,
|
||||
localizePeriods,
|
||||
localizePaintings,
|
||||
localizeAnnotations,
|
||||
localizeInfluenceSources,
|
||||
} = require('./translation-service');
|
||||
const translationRoutes = require('./routes/translations');
|
||||
const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
|
||||
|
||||
const app = express();
|
||||
@@ -30,6 +42,7 @@ app.use(compression());
|
||||
app.use(express.json({ limit: '20mb' }));
|
||||
app.use(createSessionMiddleware());
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/translations', translationRoutes);
|
||||
app.use(
|
||||
'/images',
|
||||
express.static(IMAGE_DIR, {
|
||||
@@ -119,6 +132,13 @@ function sendCatalogCacheHeaders(res, etagSource) {
|
||||
return etag;
|
||||
}
|
||||
|
||||
function localeContext(req) {
|
||||
return {
|
||||
locale: resolveLocale(req),
|
||||
statuses: translationStatuses(req),
|
||||
};
|
||||
}
|
||||
|
||||
const INFLUENCE_LINKS_EXISTS = `
|
||||
EXISTS (
|
||||
SELECT 1 FROM painting_influence_sources pis
|
||||
@@ -127,6 +147,7 @@ const INFLUENCE_LINKS_EXISTS = `
|
||||
|
||||
const INFLUENCED_BY_SQL = `
|
||||
SELECT
|
||||
pis.id AS influence_source_id,
|
||||
pis.source_type,
|
||||
pis.period_note,
|
||||
pis.period_start_year,
|
||||
@@ -163,6 +184,7 @@ const INFLUENCED_BY_SQL = `
|
||||
|
||||
const INFLUENCED_SQL = `
|
||||
SELECT
|
||||
pis.id AS influence_source_id,
|
||||
pis.notes,
|
||||
pis.source,
|
||||
pis.aspects,
|
||||
@@ -187,9 +209,11 @@ app.get('/api/search', async (req, res) => {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q : '';
|
||||
const limit = parseInt(req.query.limit, 10);
|
||||
const types = typeof req.query.types === 'string' ? req.query.types : undefined;
|
||||
const { locale } = localeContext(req);
|
||||
const result = await searchCatalog(q, {
|
||||
limit: Number.isFinite(limit) ? limit : 20,
|
||||
types,
|
||||
locale,
|
||||
});
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json(result);
|
||||
@@ -204,9 +228,12 @@ app.get('/api/timeline', async (req, res) => {
|
||||
const { start, end } = req.query;
|
||||
const startYear = parseInt(start) || -3000;
|
||||
const endYear = parseInt(end) || 2100;
|
||||
const { locale, statuses } = localeContext(req);
|
||||
|
||||
const { eras, movements } = await fetchTimelineErasAndMovements(startYear, endYear);
|
||||
res.json({ eras, movements });
|
||||
const localizedEras = await localizeEras(eras, locale, statuses);
|
||||
const localizedMovements = await localizeMovements(movements, locale, statuses);
|
||||
res.json({ locale, eras: localizedEras, movements: localizedMovements });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch timeline' });
|
||||
@@ -219,6 +246,7 @@ app.get('/api/catalog/bootstrap', async (req, res) => {
|
||||
const bounds = await fetchCatalogBounds();
|
||||
const startYear = parseInt(req.query.start, 10) || bounds.min_year || -3000;
|
||||
const endYear = parseInt(req.query.end, 10) || bounds.max_year || 2100;
|
||||
const { locale, statuses } = localeContext(req);
|
||||
|
||||
const etagSource = await catalogBootstrapEtag();
|
||||
const etag = sendCatalogCacheHeaders(res, etagSource);
|
||||
@@ -231,7 +259,17 @@ app.get('/api/catalog/bootstrap', async (req, res) => {
|
||||
fetchTimelineArtists(startYear, endYear),
|
||||
]);
|
||||
|
||||
res.json({ bounds, eras, movements, artists });
|
||||
const localizedEras = await localizeEras(eras, locale, statuses);
|
||||
const localizedMovements = await localizeMovements(movements, locale, statuses);
|
||||
const localizedArtists = await localizeArtists(artists, locale, statuses);
|
||||
|
||||
res.json({
|
||||
locale,
|
||||
bounds,
|
||||
eras: localizedEras,
|
||||
movements: localizedMovements,
|
||||
artists: localizedArtists,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch catalog bootstrap' });
|
||||
@@ -256,6 +294,7 @@ app.get('/api/movements/:id/gallery', async (req, res) => {
|
||||
const paintings = await pool.query(
|
||||
`SELECT p.*,
|
||||
a.name AS artist_name,
|
||||
a.id AS artist_id,
|
||||
p.checkup_checked,
|
||||
p.checkup_fixed,
|
||||
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
|
||||
@@ -266,9 +305,14 @@ app.get('/api/movements/:id/gallery', async (req, res) => {
|
||||
[id]
|
||||
);
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localizedMovement = (await localizeMovements([movement.rows[0]], locale, statuses))[0];
|
||||
const localizedPaintings = await localizePaintings(paintings.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
movement: movement.rows[0],
|
||||
paintings: paintings.rows.map(enrichPaintingRow),
|
||||
locale,
|
||||
movement: localizedMovement,
|
||||
paintings: localizedPaintings.map(enrichPaintingRow),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -281,12 +325,14 @@ app.get('/api/movements/:id/artists', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, birth_year, death_year, portrait_path, bio_short
|
||||
`SELECT id, name, birth_year, death_year, portrait_path, bio_short, movement_id
|
||||
FROM artists WHERE movement_id = $1
|
||||
ORDER BY birth_year`,
|
||||
[id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localized = await localizeArtists(result.rows, locale, statuses);
|
||||
res.json(localized);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch artists' });
|
||||
@@ -322,7 +368,9 @@ app.get('/api/artists', async (req, res) => {
|
||||
|
||||
query += ' ORDER BY a.birth_year';
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localized = await localizeArtists(result.rows, locale, statuses);
|
||||
res.json(localized);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch artists' });
|
||||
@@ -401,9 +449,14 @@ app.get('/api/artists/:id/navigation', async (req, res) => {
|
||||
),
|
||||
]);
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const locPred = await localizeArtists(predecessors.rows, locale, statuses);
|
||||
const locSucc = await localizeArtists(successors.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
predecessors: groupByMovement(predecessors.rows),
|
||||
successors: groupByMovement(successors.rows),
|
||||
locale,
|
||||
predecessors: groupByMovement(locPred),
|
||||
successors: groupByMovement(locSucc),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -645,10 +698,16 @@ app.get('/api/artists/:id', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Artist not found' });
|
||||
}
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localizedArtist = (await localizeArtists([artist.rows[0]], locale, statuses))[0];
|
||||
const localizedPeriods = await localizePeriods(periods.rows, locale, statuses);
|
||||
const localizedPaintings = await localizePaintings(paintings.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
artist: enrichArtistRow(artist.rows[0]),
|
||||
periods: periods.rows,
|
||||
paintings: paintings.rows.map(enrichPaintingRow),
|
||||
locale,
|
||||
artist: enrichArtistRow(localizedArtist),
|
||||
periods: localizedPeriods,
|
||||
paintings: localizedPaintings.map(enrichPaintingRow),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -803,11 +862,22 @@ app.get('/api/paintings/:id', async (req, res) => {
|
||||
),
|
||||
]);
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localizedPainting = (await localizePaintings([painting.rows[0]], locale, statuses))[0];
|
||||
const localizedInfluencedBy = await localizeInfluenceSources(influencedBy.rows, locale, statuses);
|
||||
const localizedInfluenced = await localizeInfluenceSources(
|
||||
influenced.rows.map(enrichPaintingRow),
|
||||
locale,
|
||||
statuses,
|
||||
);
|
||||
const localizedAnnotations = await localizeAnnotations(annotations.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
painting: enrichPaintingRow(painting.rows[0]),
|
||||
influencedBy: influencedBy.rows,
|
||||
influenced: influenced.rows.map(enrichPaintingRow),
|
||||
annotations: annotations.rows,
|
||||
locale,
|
||||
painting: enrichPaintingRow(localizedPainting),
|
||||
influencedBy: localizedInfluencedBy,
|
||||
influenced: localizedInfluenced,
|
||||
annotations: localizedAnnotations,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -14,6 +14,7 @@ const INCREMENTAL_MIGRATIONS = [
|
||||
'migrate-perf-indexes.sql',
|
||||
'migrate-search.sql',
|
||||
'migrate-sync-timestamps.sql',
|
||||
'migrate-i18n.sql',
|
||||
];
|
||||
|
||||
async function bootstrapCurator() {
|
||||
|
||||
@@ -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;
|
||||
+173
-28
@@ -2,6 +2,17 @@ const pool = require('./db');
|
||||
|
||||
const VALID_TYPES = new Set(['artist', 'painting', 'movement']);
|
||||
|
||||
const TRANSLATION_MATCH = (entityType, entityAlias, fieldName, localeParam) => `
|
||||
EXISTS (
|
||||
SELECT 1 FROM entity_translations t
|
||||
WHERE t.entity_type = '${entityType}'
|
||||
AND t.entity_id = ${entityAlias}.id
|
||||
AND t.locale = ${localeParam}
|
||||
AND t.field_name = '${fieldName}'
|
||||
AND t.status IN ('published', 'reviewed')
|
||||
AND t.value ILIKE $1
|
||||
)`;
|
||||
|
||||
function parseTypes(typesParam) {
|
||||
if (!typesParam || typeof typesParam !== 'string') {
|
||||
return ['artist', 'movement', 'painting'];
|
||||
@@ -13,10 +24,18 @@ function parseTypes(typesParam) {
|
||||
return parsed.length > 0 ? parsed : ['artist', 'movement', 'painting'];
|
||||
}
|
||||
|
||||
async function searchArtists(pattern, prefixPattern, perTypeLimit) {
|
||||
async function searchArtists(pattern, prefixPattern, perTypeLimit, locale = 'en') {
|
||||
const localeParam = locale !== 'en' ? '$4' : null;
|
||||
const translationClause = locale !== 'en'
|
||||
? ` OR ${TRANSLATION_MATCH('artist', 'a', 'name', localeParam)}`
|
||||
: '';
|
||||
const params = locale !== 'en'
|
||||
? [pattern, prefixPattern, perTypeLimit, locale]
|
||||
: [pattern, prefixPattern, perTypeLimit];
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.id, a.name, a.birth_year, a.death_year,
|
||||
a.portrait_path, a.portrait_thumb_path,
|
||||
a.portrait_path, a.portrait_thumb_path, a.movement_id,
|
||||
m.name AS movement_name,
|
||||
CASE WHEN a.name ILIKE $2 THEN 0 ELSE 1 END AS rank
|
||||
FROM artists a
|
||||
@@ -24,49 +43,140 @@ async function searchArtists(pattern, prefixPattern, perTypeLimit) {
|
||||
WHERE a.name ILIKE $1
|
||||
OR a.wikipedia_title ILIKE $1
|
||||
OR m.name ILIKE $1
|
||||
${translationClause}
|
||||
ORDER BY rank, a.name
|
||||
LIMIT $3`,
|
||||
[pattern, prefixPattern, perTypeLimit]
|
||||
params,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
type: 'artist',
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
birth_year: row.birth_year,
|
||||
death_year: row.death_year,
|
||||
movement_name: row.movement_name,
|
||||
portrait_path: row.portrait_path,
|
||||
portrait_thumb_path: row.portrait_thumb_path,
|
||||
rank: row.rank,
|
||||
}));
|
||||
|
||||
if (locale === 'en') {
|
||||
return rows.map((row) => ({
|
||||
type: 'artist',
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
birth_year: row.birth_year,
|
||||
death_year: row.death_year,
|
||||
movement_name: row.movement_name,
|
||||
portrait_path: row.portrait_path,
|
||||
portrait_thumb_path: row.portrait_thumb_path,
|
||||
rank: row.rank,
|
||||
}));
|
||||
}
|
||||
|
||||
const {
|
||||
loadTranslationsByType,
|
||||
PUBLIC_STATUSES,
|
||||
} = require('./translation-service');
|
||||
|
||||
const artistIds = rows.map((r) => r.id);
|
||||
const movementIds = rows.map((r) => r.movement_id).filter(Boolean);
|
||||
const artistMap = await loadTranslationsByType('artist', artistIds, locale, ['name'], PUBLIC_STATUSES);
|
||||
const movementMap = movementIds.length
|
||||
? await loadTranslationsByType('movement', movementIds, locale, ['name'], PUBLIC_STATUSES)
|
||||
: new Map();
|
||||
|
||||
const localized = rows.map((row) => {
|
||||
const name = artistMap.get(`${row.id}:name`) || row.name;
|
||||
let movement_name = row.movement_name;
|
||||
if (row.movement_id && movementMap.has(`${row.movement_id}:name`)) {
|
||||
movement_name = movementMap.get(`${row.movement_id}:name`);
|
||||
}
|
||||
return {
|
||||
type: 'artist',
|
||||
id: row.id,
|
||||
name,
|
||||
birth_year: row.birth_year,
|
||||
death_year: row.death_year,
|
||||
movement_name,
|
||||
portrait_path: row.portrait_path,
|
||||
portrait_thumb_path: row.portrait_thumb_path,
|
||||
rank: row.rank,
|
||||
};
|
||||
});
|
||||
|
||||
return localized;
|
||||
}
|
||||
|
||||
async function searchMovements(pattern, prefixPattern, perTypeLimit) {
|
||||
async function searchMovements(pattern, prefixPattern, perTypeLimit, locale = 'en') {
|
||||
const localeParam = locale !== 'en' ? '$4' : null;
|
||||
const movementTrans = locale !== 'en'
|
||||
? ` OR ${TRANSLATION_MATCH('movement', 'm', 'name', localeParam)}`
|
||||
: '';
|
||||
const eraTrans = locale !== 'en'
|
||||
? ` OR ${TRANSLATION_MATCH('era', 'e', 'name', localeParam)}`
|
||||
: '';
|
||||
const params = locale !== 'en'
|
||||
? [pattern, prefixPattern, perTypeLimit, locale]
|
||||
: [pattern, prefixPattern, perTypeLimit];
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT m.id, m.name, m.color, m.start_year, m.end_year,
|
||||
`SELECT m.id, m.name, m.color, m.start_year, m.end_year, m.era_id,
|
||||
e.name AS era_name,
|
||||
CASE WHEN m.name ILIKE $2 THEN 0 ELSE 1 END AS rank
|
||||
FROM art_movements m
|
||||
LEFT JOIN historical_eras e ON m.era_id = e.id
|
||||
WHERE m.name ILIKE $1
|
||||
OR e.name ILIKE $1
|
||||
${movementTrans}
|
||||
${eraTrans}
|
||||
ORDER BY rank, m.name
|
||||
LIMIT $3`,
|
||||
[pattern, prefixPattern, perTypeLimit]
|
||||
params,
|
||||
);
|
||||
|
||||
if (locale === 'en') {
|
||||
return rows.map((row) => ({
|
||||
type: 'movement',
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
color: row.color,
|
||||
start_year: row.start_year,
|
||||
end_year: row.end_year,
|
||||
era_name: row.era_name,
|
||||
rank: row.rank,
|
||||
}));
|
||||
}
|
||||
|
||||
const { loadTranslationsByType, PUBLIC_STATUSES } = require('./translation-service');
|
||||
const movementMap = await loadTranslationsByType(
|
||||
'movement',
|
||||
rows.map((r) => r.id),
|
||||
locale,
|
||||
['name'],
|
||||
PUBLIC_STATUSES,
|
||||
);
|
||||
const eraMap = await loadTranslationsByType(
|
||||
'era',
|
||||
rows.map((r) => r.era_id).filter(Boolean),
|
||||
locale,
|
||||
['name'],
|
||||
PUBLIC_STATUSES,
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
type: 'movement',
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
name: movementMap.get(`${row.id}:name`) || row.name,
|
||||
color: row.color,
|
||||
start_year: row.start_year,
|
||||
end_year: row.end_year,
|
||||
era_name: row.era_name,
|
||||
era_name: (row.era_id && eraMap.get(`${row.era_id}:name`)) || row.era_name,
|
||||
rank: row.rank,
|
||||
}));
|
||||
}
|
||||
|
||||
async function searchPaintings(pattern, prefixPattern, perTypeLimit) {
|
||||
async function searchPaintings(pattern, prefixPattern, perTypeLimit, locale = 'en') {
|
||||
const localeParam = locale !== 'en' ? '$4' : null;
|
||||
const paintingTrans = locale !== 'en'
|
||||
? ` OR ${TRANSLATION_MATCH('painting', 'p', 'title', localeParam)}`
|
||||
: '';
|
||||
const artistTrans = locale !== 'en'
|
||||
? ` OR ${TRANSLATION_MATCH('artist', 'a', 'name', localeParam)}`
|
||||
: '';
|
||||
const params = locale !== 'en'
|
||||
? [pattern, prefixPattern, perTypeLimit, locale]
|
||||
: [pattern, prefixPattern, perTypeLimit];
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.id, p.title, p.year, p.thumbnail_path, p.image_path,
|
||||
a.id AS artist_id, a.name AS artist_name,
|
||||
@@ -80,17 +190,51 @@ async function searchPaintings(pattern, prefixPattern, perTypeLimit) {
|
||||
OR a.name ILIKE $1
|
||||
OR m.name ILIKE $1
|
||||
OR CAST(p.year AS TEXT) ILIKE $1
|
||||
${paintingTrans}
|
||||
${artistTrans}
|
||||
ORDER BY rank, p.year NULLS LAST, p.title
|
||||
LIMIT $3`,
|
||||
[pattern, prefixPattern, perTypeLimit]
|
||||
params,
|
||||
);
|
||||
|
||||
if (locale === 'en') {
|
||||
return rows.map((row) => ({
|
||||
type: 'painting',
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
year: row.year,
|
||||
artist_id: row.artist_id,
|
||||
artist_name: row.artist_name,
|
||||
movement_name: row.movement_name,
|
||||
thumbnail_path: row.thumbnail_path,
|
||||
image_path: row.image_path,
|
||||
rank: row.rank,
|
||||
}));
|
||||
}
|
||||
|
||||
const { loadTranslationsByType, PUBLIC_STATUSES } = require('./translation-service');
|
||||
const paintingMap = await loadTranslationsByType(
|
||||
'painting',
|
||||
rows.map((r) => r.id),
|
||||
locale,
|
||||
['title'],
|
||||
PUBLIC_STATUSES,
|
||||
);
|
||||
const artistMap = await loadTranslationsByType(
|
||||
'artist',
|
||||
rows.map((r) => r.artist_id),
|
||||
locale,
|
||||
['name'],
|
||||
PUBLIC_STATUSES,
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
type: 'painting',
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
title: paintingMap.get(`${row.id}:title`) || row.title,
|
||||
year: row.year,
|
||||
artist_id: row.artist_id,
|
||||
artist_name: row.artist_name,
|
||||
artist_name: artistMap.get(`${row.artist_id}:name`) || row.artist_name,
|
||||
movement_name: row.movement_name,
|
||||
thumbnail_path: row.thumbnail_path,
|
||||
image_path: row.image_path,
|
||||
@@ -101,9 +245,10 @@ async function searchPaintings(pattern, prefixPattern, perTypeLimit) {
|
||||
async function searchCatalog(query, options = {}) {
|
||||
const q = String(query || '').trim();
|
||||
if (q.length < 2) {
|
||||
return { q, results: [] };
|
||||
return { q, locale: options.locale || 'en', results: [] };
|
||||
}
|
||||
|
||||
const locale = options.locale || 'en';
|
||||
const limit = Math.min(50, Math.max(1, Number(options.limit) || 20));
|
||||
const types = parseTypes(options.types);
|
||||
const perTypeLimit = Math.max(1, Math.ceil(limit / types.length));
|
||||
@@ -111,9 +256,9 @@ async function searchCatalog(query, options = {}) {
|
||||
const prefixPattern = `${q}%`;
|
||||
|
||||
const tasks = [];
|
||||
if (types.includes('artist')) tasks.push(searchArtists(pattern, prefixPattern, perTypeLimit));
|
||||
if (types.includes('movement')) tasks.push(searchMovements(pattern, prefixPattern, perTypeLimit));
|
||||
if (types.includes('painting')) tasks.push(searchPaintings(pattern, prefixPattern, perTypeLimit));
|
||||
if (types.includes('artist')) tasks.push(searchArtists(pattern, prefixPattern, perTypeLimit, locale));
|
||||
if (types.includes('movement')) tasks.push(searchMovements(pattern, prefixPattern, perTypeLimit, locale));
|
||||
if (types.includes('painting')) tasks.push(searchPaintings(pattern, prefixPattern, perTypeLimit, locale));
|
||||
|
||||
const groups = await Promise.all(tasks);
|
||||
const merged = groups
|
||||
@@ -129,7 +274,7 @@ async function searchCatalog(query, options = {}) {
|
||||
.slice(0, limit)
|
||||
.map(({ rank: _rank, ...rest }) => rest);
|
||||
|
||||
return { q, results: merged };
|
||||
return { q, locale, results: merged };
|
||||
}
|
||||
|
||||
module.exports = { searchCatalog };
|
||||
|
||||
@@ -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