Expand the gallery with prev/next browsing and fullscreen detail view, golden influence lamps and chronological wall layout in 3D halls, and scripts/docs for catalog expansion, influence edges, and multi-source image fetching. Co-authored-by: Cursor <cursoragent@cursor.com>
217 lines
6.5 KiB
JavaScript
217 lines
6.5 KiB
JavaScript
require('dotenv').config();
|
|
const path = require('path');
|
|
const pool = require('../server/db');
|
|
const INFLUENCES = require('./art-influences-data');
|
|
const { savePaintingImages } = require('./image-fetcher');
|
|
|
|
const FETCH_IMAGES = process.argv.includes('--fetch-images');
|
|
const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || path.join(__dirname, '../data/images'));
|
|
|
|
const ARTIST_ALIASES = {
|
|
'Camille Corot': 'Jean-Baptiste-Camille Corot',
|
|
};
|
|
|
|
function normalizeTitle(s) {
|
|
return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
}
|
|
|
|
function normalizeArtist(name) {
|
|
return ARTIST_ALIASES[name] || name;
|
|
}
|
|
|
|
function scoreTitleMatch(dbTitle, hint) {
|
|
const normDb = normalizeTitle(dbTitle);
|
|
const keywords = normalizeTitle(hint).split(' ').filter((w) => w.length > 2);
|
|
if (!keywords.length) return 0;
|
|
const hits = keywords.filter((k) => normDb.includes(k)).length;
|
|
const required = keywords.length === 1 ? 1 : Math.min(2, keywords.length);
|
|
return hits >= required ? hits : 0;
|
|
}
|
|
|
|
async function loadMovements() {
|
|
const { rows } = await pool.query('SELECT id, name FROM art_movements');
|
|
const byName = new Map(rows.map((r) => [r.name.toLowerCase(), r.id]));
|
|
return byName;
|
|
}
|
|
|
|
async function findArtist(name) {
|
|
const canonical = normalizeArtist(name);
|
|
const { rows } = await pool.query(
|
|
`SELECT id, name FROM artists WHERE name = $1 OR name ILIKE $2 LIMIT 1`,
|
|
[canonical, canonical]
|
|
);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function ensureArtist(ref, movementsByName) {
|
|
const name = normalizeArtist(ref.artist);
|
|
let artist = await findArtist(name);
|
|
if (artist) return artist.id;
|
|
|
|
const meta = ref.artistMeta;
|
|
if (!meta) return null;
|
|
|
|
const movementId = meta.movement
|
|
? movementsByName.get(meta.movement.toLowerCase()) || null
|
|
: null;
|
|
const century =
|
|
meta.century ||
|
|
(meta.birth_year ? Math.floor(meta.birth_year / 100) * 100 : null);
|
|
|
|
const insert = await pool.query(
|
|
`INSERT INTO artists (name, birth_year, death_year, movement_id, wikipedia_title, century, portrait_path)
|
|
VALUES ($1, $2, $3, $4, $5, $6, NULL)
|
|
RETURNING id, name`,
|
|
[
|
|
name,
|
|
meta.birth_year ?? null,
|
|
meta.death_year ?? null,
|
|
movementId,
|
|
meta.wikipedia_title || name,
|
|
century,
|
|
]
|
|
);
|
|
console.log(`+ artist: ${name}`);
|
|
return insert.rows[0].id;
|
|
}
|
|
|
|
async function findPainting(artistId, titleHint) {
|
|
const { rows } = await pool.query(
|
|
'SELECT id, title FROM paintings WHERE artist_id = $1',
|
|
[artistId]
|
|
);
|
|
let best = null;
|
|
let bestScore = 0;
|
|
for (const row of rows) {
|
|
const score = scoreTitleMatch(row.title, titleHint);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = row;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
async function ensurePainting(artistId, ref, artistName) {
|
|
const existing = await findPainting(artistId, ref.title);
|
|
if (existing) return existing.id;
|
|
|
|
const sortRes = await pool.query(
|
|
'SELECT COALESCE(MAX(sort_order), 0) + 1 AS next FROM paintings WHERE artist_id = $1',
|
|
[artistId]
|
|
);
|
|
const wikiTitle = ref.wikipedia_title || ref.title;
|
|
const insert = await pool.query(
|
|
`INSERT INTO paintings (artist_id, title, year, wikipedia_title, sort_order)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id`,
|
|
[artistId, ref.title, ref.year ?? null, wikiTitle, sortRes.rows[0].next]
|
|
);
|
|
console.log(`+ painting: ${artistName} — ${ref.title}`);
|
|
|
|
if (FETCH_IMAGES) {
|
|
try {
|
|
const base = `${artistName}_${ref.title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
const saved = await savePaintingImages(wikiTitle, base, IMAGE_DIR, {
|
|
artistName,
|
|
paintingTitle: ref.title,
|
|
});
|
|
if (saved.imagePath || saved.thumbnailPath) {
|
|
await pool.query(
|
|
`UPDATE paintings SET image_path = $1, thumbnail_path = $2 WHERE id = $3`,
|
|
[saved.imagePath, saved.thumbnailPath, insert.rows[0].id]
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.warn(` image fetch failed: ${artistName} — ${ref.title}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
return insert.rows[0].id;
|
|
}
|
|
|
|
async function resolvePaintingRef(ref, movementsByName) {
|
|
const artistName = normalizeArtist(ref.artist);
|
|
const artistId = await ensureArtist(ref, movementsByName);
|
|
if (!artistId) return null;
|
|
return ensurePainting(artistId, ref, artistName);
|
|
}
|
|
|
|
async function insertInfluence(workId, sourceId, edge) {
|
|
const result = await pool.query(
|
|
`INSERT INTO painting_influences
|
|
(painting_id, influenced_by_painting_id, notes, source, aspects, quote, source_author, source_url)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (painting_id, influenced_by_painting_id) DO NOTHING
|
|
RETURNING id`,
|
|
[
|
|
workId,
|
|
sourceId,
|
|
edge.notes || null,
|
|
edge.source || null,
|
|
edge.aspects || null,
|
|
edge.quote || null,
|
|
edge.source_author || null,
|
|
edge.source_url || null,
|
|
]
|
|
);
|
|
return result.rowCount > 0;
|
|
}
|
|
|
|
async function main() {
|
|
const movementsByName = await loadMovements();
|
|
let added = 0;
|
|
let skipped = 0;
|
|
let failed = 0;
|
|
|
|
for (const edge of INFLUENCES) {
|
|
try {
|
|
const workId = await resolvePaintingRef(edge.work, movementsByName);
|
|
const sourceId = await resolvePaintingRef(edge.influencedBy, movementsByName);
|
|
|
|
if (!workId || !sourceId) {
|
|
failed += 1;
|
|
console.warn(
|
|
`✗ unresolved: ${edge.work.artist} / ${edge.work.title} <- ${edge.influencedBy?.artist} / ${edge.influencedBy?.title}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (workId === sourceId) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
const inserted = await insertInfluence(workId, sourceId, edge);
|
|
if (inserted) {
|
|
added += 1;
|
|
console.log(`→ ${edge.work.artist} / ${edge.work.title} ← ${edge.influencedBy.artist} / ${edge.influencedBy.title}`);
|
|
} else {
|
|
skipped += 1;
|
|
}
|
|
} catch (err) {
|
|
failed += 1;
|
|
console.warn(`✗ ${edge.work?.artist}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
const total = await pool.query('SELECT COUNT(*)::int AS n FROM painting_influences');
|
|
const connected = await pool.query(`
|
|
SELECT COUNT(DISTINCT a.id)::int AS n
|
|
FROM artists a
|
|
JOIN paintings p ON p.artist_id = a.id
|
|
JOIN painting_influences pi ON pi.painting_id = p.id OR pi.influenced_by_painting_id = p.id
|
|
`);
|
|
|
|
console.log(`\nDone: ${added} added, ${skipped} skipped (duplicate/self), ${failed} failed`);
|
|
console.log(`Total influence edges: ${total.rows[0].n}`);
|
|
console.log(`Artists connected to influence graph: ${connected.rows[0].n}`);
|
|
|
|
await pool.end();
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|