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