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