Improve timeline UX and add catalog restore scripts for paintings and portraits.

Load the home-page catalog once with a lightweight artists API, batch pan/zoom updates per frame, use dynamic year labels, and speed up movement-flow zoom. Add sync-image-paths and fetch-artist-images plus docs for the post-seed pipeline.
This commit is contained in:
Danila Khodjaef
2026-07-03 18:33:16 +03:00
parent 7f26696328
commit 02d238b043
26 changed files with 865 additions and 195 deletions
+80
View File
@@ -0,0 +1,80 @@
/**
* Download artist portrait images from Wikipedia (and fallbacks) into data/images/portraits/.
* Run: npm run fetch-artist-images
* Flags: --force (re-fetch even when portrait_path is set), --limit=N
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pool = require('../server/db');
const { saveArtistPortrait, findLocalPortraitPath } = require('./image-fetcher');
const FORCE = process.argv.includes('--force');
const LIMIT = parseInt(process.argv.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0', 10);
const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || path.join(__dirname, '../data/images'));
function portraitFileExists(portraitPath) {
return !!portraitPath && fs.existsSync(path.join(IMAGE_DIR, portraitPath));
}
function needsPortrait(artist, force) {
if (force) return true;
const local = findLocalPortraitPath(artist.name, IMAGE_DIR);
if (local) return artist.portrait_path !== local;
if (artist.portrait_path) return !portraitFileExists(artist.portrait_path);
return true;
}
async function main() {
const { rows: artists } = await pool.query(`
SELECT id, name, wikipedia_title, portrait_path
FROM artists
ORDER BY name
`);
let targets = artists.filter((a) => needsPortrait(a, FORCE));
if (LIMIT > 0) targets = targets.slice(0, LIMIT);
console.log(
`Artists total: ${artists.length}, to fetch: ${targets.length}${FORCE ? ' (force)' : ''}${LIMIT > 0 ? ` (limit ${LIMIT})` : ''}`
);
let updated = 0;
let linked = 0;
let failed = 0;
for (const artist of targets) {
try {
const local = findLocalPortraitPath(artist.name, IMAGE_DIR);
if (local) {
await pool.query('UPDATE artists SET portrait_path = $1 WHERE id = $2', [local, artist.id]);
console.log(`${artist.name}${local}`);
linked += 1;
continue;
}
const wikiTitle = artist.wikipedia_title || artist.name;
const saved = await saveArtistPortrait(artist.name, wikiTitle, IMAGE_DIR);
if (!saved.path) {
console.warn(`${artist.name} — no portrait found`);
failed += 1;
continue;
}
await pool.query('UPDATE artists SET portrait_path = $1 WHERE id = $2', [saved.path, artist.id]);
console.log(`${artist.name}${saved.path} (${saved.source || 'Wikipedia'})`);
updated += 1;
} catch (err) {
console.error(`${artist.name}${err.message}`);
failed += 1;
}
}
console.log(`\nDone: ${updated} downloaded, ${linked} linked from disk, ${failed} failed`);
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+82
View File
@@ -1807,11 +1807,93 @@ async function saveImageForItem(wikiTitle, subdir, filename, imageDir, options =
}
}
const ARTIST_PORTRAIT_WIKI_OVERRIDES = {
Zeuxis: 'Zeuxis (painter)',
'Ivan Klyun': 'Ivan Kliun',
'Jean-Antoine Watteau': 'Antoine Watteau',
};
function artistPortraitWikiCandidates(artistName, wikiTitle) {
const seen = new Set();
const out = [];
for (const title of [
ARTIST_PORTRAIT_WIKI_OVERRIDES[artistName],
ARTIST_PORTRAIT_WIKI_OVERRIDES[wikiTitle],
wikiTitle,
artistName,
`${artistName} (painter)`,
`${wikiTitle} (painter)`,
]) {
if (title && !seen.has(title)) {
seen.add(title);
out.push(title);
}
}
return out;
}
async function resolveArtistPortrait(artistName, wikiTitle) {
for (const title of artistPortraitWikiCandidates(artistName, wikiTitle)) {
const images = await getWikipediaImages(title);
if (images?.fullUrl) {
return { ...images, wikipedia_title: title };
}
}
const search = await searchArtistPortraitFirst(artistName);
if (search?.imageUrl) {
return {
fullUrl: search.imageUrl,
source: search.sourceLabel || search.source || 'web search',
wikipedia_title: wikiTitle,
};
}
return null;
}
function findLocalPortraitPath(artistName, imageDir) {
const safeBase = artistName.replace(/[^a-zA-Z0-9_-]/g, '_');
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) {
const rel = `portraits/${safeBase}${ext}`;
if (fs.existsSync(path.join(imageDir, rel))) return rel.replace(/\\/g, '/');
}
return null;
}
async function saveArtistPortrait(artistName, wikiTitle, imageDir) {
const local = findLocalPortraitPath(artistName, imageDir);
if (local) return { path: local, source: 'local disk' };
const resolved = await resolveArtistPortrait(artistName, wikiTitle);
if (!resolved?.fullUrl) return { path: null, source: null };
const portraitsDir = path.join(imageDir, 'portraits');
if (!fs.existsSync(portraitsDir)) fs.mkdirSync(portraitsDir, { recursive: true });
const safeBase = artistName.replace(/[^a-zA-Z0-9_-]/g, '_');
const ext = pickExt(resolved.fullUrl);
const destPath = path.join(portraitsDir, safeBase + ext);
try {
await downloadImageToFile(resolved.fullUrl, destPath);
return {
path: path.join('portraits', safeBase + ext).replace(/\\/g, '/'),
source: resolved.source,
};
} catch (err) {
console.warn(` Portrait download failed: ${err.message}`);
return { path: null, source: resolved.source };
}
}
module.exports = {
resolveImageUrl,
resolvePaintingImages,
saveImageForItem,
savePaintingImages,
saveArtistPortrait,
findLocalPortraitPath,
resolveArtistPortrait,
downloadImageToFile,
downloadImageForFix,
generateThumbnailFromFull,
+200
View File
@@ -0,0 +1,200 @@
/**
* Align painting image_path / thumbnail_path with files on disk and import missing rows.
* Run: npm run sync-image-paths
* Flags: --dry-run (report only, no DB writes)
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pool = require('../server/db');
const DRY_RUN = process.argv.includes('--dry-run');
const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || path.join(__dirname, '../data/images'));
const PAINTINGS_DIR = path.join(IMAGE_DIR, 'paintings');
const THUMBS_DIR = path.join(IMAGE_DIR, 'paintings', 'thumbs');
function safeArtistBase(name) {
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function safePaintingBase(artistName, title) {
return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function normalizeTitle(s) {
return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function slugToTitle(slug) {
return slug.replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
}
function findPathsForSafeBase(safeBase) {
let imagePath = null;
let thumbPath = null;
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) {
const full = path.join(PAINTINGS_DIR, safeBase + ext);
if (!imagePath && fs.existsSync(full)) {
imagePath = `paintings/${safeBase}${ext}`.replace(/\\/g, '/');
}
const thumb = path.join(THUMBS_DIR, safeBase + '_thumb' + ext);
if (!thumbPath && fs.existsSync(thumb)) {
thumbPath = `paintings/thumbs/${safeBase}_thumb${ext}`.replace(/\\/g, '/');
}
}
if (!thumbPath && imagePath) thumbPath = imagePath;
return { imagePath, thumbPath };
}
function matchArtistFromFilename(filename, artistsByPrefix) {
for (const { prefix, artist } of artistsByPrefix) {
if (filename === prefix || filename.startsWith(prefix + '_')) {
return { artist, prefix };
}
}
return null;
}
async function main() {
if (!fs.existsSync(PAINTINGS_DIR)) {
console.error(`Paintings directory not found: ${PAINTINGS_DIR}`);
process.exit(1);
}
const artistsRes = await pool.query('SELECT id, name FROM artists ORDER BY name');
const artistsByPrefix = artistsRes.rows
.map((a) => ({ prefix: safeArtistBase(a.name), artist: a }))
.sort((a, b) => b.prefix.length - a.prefix.length);
const paintingsRes = await pool.query(`
SELECT p.id, p.title, p.image_path, p.thumbnail_path, p.artist_id, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
`);
const bySafeBase = new Map();
const titlesByArtist = new Map();
for (const row of paintingsRes.rows) {
const base = safePaintingBase(row.artist_name, row.title);
bySafeBase.set(base, row);
if (!titlesByArtist.has(row.artist_id)) titlesByArtist.set(row.artist_id, new Set());
titlesByArtist.get(row.artist_id).add(normalizeTitle(row.title));
}
const diskFiles = fs
.readdirSync(PAINTINGS_DIR)
.filter((f) => /\.(jpg|jpeg|png|webp)$/i.test(f) && !f.includes('_thumb'));
let linked = 0;
let imported = 0;
let skipped = 0;
let unmatched = 0;
for (const file of diskFiles) {
const ext = path.extname(file);
const safeBase = file.slice(0, -ext.length);
const paths = findPathsForSafeBase(safeBase);
if (!paths.imagePath && !paths.thumbPath) continue;
const existing = bySafeBase.get(safeBase);
if (existing) {
const needsUpdate =
(paths.imagePath && existing.image_path !== paths.imagePath) ||
(paths.thumbPath && existing.thumbnail_path !== paths.thumbPath);
if (needsUpdate) {
if (!DRY_RUN) {
await pool.query(
`UPDATE paintings SET image_path = COALESCE($1, image_path), thumbnail_path = COALESCE($2, thumbnail_path) WHERE id = $3`,
[paths.imagePath, paths.thumbPath, existing.id]
);
}
linked += 1;
}
continue;
}
const matched = matchArtistFromFilename(safeBase, artistsByPrefix);
if (!matched) {
unmatched += 1;
continue;
}
const titleSlug = safeBase.slice(matched.prefix.length + 1);
if (!titleSlug) {
skipped += 1;
continue;
}
const title = slugToTitle(titleSlug);
const norm = normalizeTitle(title);
const artistTitles = titlesByArtist.get(matched.artist.id) || new Set();
if (artistTitles.has(norm)) {
skipped += 1;
continue;
}
const sortRes = await pool.query(
'SELECT COALESCE(MAX(sort_order), 0) + 1 AS next FROM paintings WHERE artist_id = $1',
[matched.artist.id]
);
const sortOrder = sortRes.rows[0].next;
if (!DRY_RUN) {
const insert = await pool.query(
`INSERT INTO paintings (artist_id, title, wikipedia_title, image_path, thumbnail_path, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
[matched.artist.id, title, title, paths.imagePath, paths.thumbPath, sortOrder]
);
bySafeBase.set(safeBase, {
id: insert.rows[0].id,
artist_name: matched.artist.name,
title,
image_path: paths.imagePath,
thumbnail_path: paths.thumbPath,
});
}
artistTitles.add(norm);
titlesByArtist.set(matched.artist.id, artistTitles);
imported += 1;
}
// Link paths for existing rows whose files use the canonical safe base name
for (const row of paintingsRes.rows) {
if (row.image_path && row.thumbnail_path) continue;
const synced = findPathsForSafeBase(safePaintingBase(row.artist_name, row.title));
if (!synced.imagePath && !synced.thumbPath) continue;
if (!DRY_RUN) {
await pool.query(
`UPDATE paintings SET image_path = COALESCE($1, image_path), thumbnail_path = COALESCE($2, thumbnail_path) WHERE id = $3`,
[synced.imagePath, synced.thumbPath, row.id]
);
}
linked += 1;
}
const summary = await pool.query(`
SELECT COUNT(*)::int AS total_paintings,
COUNT(DISTINCT artist_id)::int AS artists_with_works,
ROUND(AVG(cnt), 1) AS avg_per_artist,
MAX(cnt)::int AS max_per_artist
FROM (
SELECT p.artist_id, COUNT(p.id)::int AS cnt
FROM paintings p
GROUP BY p.artist_id
) t
`);
console.log(
`${DRY_RUN ? '[dry-run] ' : ''}Linked ${linked}, imported ${imported}, skipped ${skipped}, unmatched files ${unmatched}`
);
console.log('Catalog:', summary.rows[0]);
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});