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; } function normalizeInfluencedBy(ref) { if (!ref) return []; if (Array.isArray(ref)) return ref; return [{ type: 'painting', ...ref }]; } function periodFields(ref, workYear) { const period = ref.period || {}; let start = period.start ?? period.startYear ?? null; let end = period.end ?? period.endYear ?? null; let note = period.note || null; if (period.duringCreation && workYear != null) { start = start ?? workYear - 3; end = end ?? workYear + 1; note = note || `around the creation of the work (${workYear})`; } return { period_start_year: start, period_end_year: end, period_note: note }; } async function loadMovements(pool) { const { rows } = await pool.query('SELECT id, name FROM art_movements'); return new Map(rows.map((r) => [r.name.toLowerCase(), r.id])); } async function findArtist(pool, 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(pool, ref, movementsByName) { const name = normalizeArtist(ref.artist); let artist = await findArtist(pool, 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(pool, artistId, titleHint) { const { rows } = await pool.query( 'SELECT id, title, year 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(pool, artistId, ref, artistName, fetchImages, imageDir, savePaintingImages) { const existing = await findPainting(pool, artistId, ref.title); if (existing) return existing; 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 (fetchImages && savePaintingImages) { try { const base = `${artistName}_${ref.title}`.replace(/[^a-zA-Z0-9_-]/g, '_'); const saved = await savePaintingImages(wikiTitle, base, imageDir, { 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]; } async function resolveMovement(pool, movementName, movementsByName) { if (!movementName) return null; const id = movementsByName.get(movementName.toLowerCase()); if (id) return id; const { rows } = await pool.query( `SELECT id FROM art_movements WHERE name ILIKE $1 LIMIT 1`, [movementName] ); return rows[0]?.id ?? null; } async function resolvePaintingRef(pool, ref, movementsByName, fetchImages, imageDir, savePaintingImages) { const artistName = normalizeArtist(ref.artist); const artistId = await ensureArtist(pool, ref, movementsByName); if (!artistId) return null; return ensurePainting(pool, artistId, ref, artistName, fetchImages, imageDir, savePaintingImages); } async function resolveWorkRef(pool, workRef, movementsByName, fetchImages, imageDir, savePaintingImages) { const artistName = normalizeArtist(workRef.artist); const artistId = await ensureArtist(pool, workRef, movementsByName); if (!artistId) return null; const painting = await ensurePainting( pool, artistId, workRef, artistName, fetchImages, imageDir, savePaintingImages ); if (!painting) return null; return { id: painting.id, year: painting.year ?? workRef.year ?? null, artist_id: artistId }; } async function insertLegacyPaintingEdge(pool, workId, sourcePaintingId, edge) { 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`, [ workId, sourcePaintingId, edge.notes || null, edge.source || null, edge.aspects || null, edge.quote || null, edge.source_author || null, edge.source_url || null, ] ); } async function insertInfluenceSource(pool, workId, sourceType, sourceIds, edge, workYear) { const period = periodFields(edge, workYear); const result = await pool.query( `INSERT INTO painting_influence_sources ( painting_id, source_type, source_painting_id, source_artist_id, source_movement_id, period_note, period_start_year, period_end_year, notes, source, aspects, quote, source_author, source_url, discovered_via, confidence ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT DO NOTHING RETURNING id`, [ workId, sourceType, sourceIds.source_painting_id ?? null, sourceIds.source_artist_id ?? null, sourceIds.source_movement_id ?? null, period.period_note, period.period_start_year, period.period_end_year, edge.notes || null, edge.source || null, edge.aspects || null, edge.quote || null, edge.source_author || null, edge.source_url || null, edge.discovered_via || null, edge.confidence || 'curated', ] ); return result.rowCount > 0; } async function resolveAndInsertSource( pool, work, sourceRef, edge, movementsByName, fetchImages, imageDir, savePaintingImages ) { const type = (sourceRef.type || 'painting').toLowerCase(); const mergedEdge = { ...edge, ...sourceRef, type }; if (type === 'painting') { const sourcePainting = await resolvePaintingRef( pool, sourceRef, movementsByName, fetchImages, imageDir, savePaintingImages ); if (!sourcePainting) return { ok: false, reason: 'unresolved painting' }; if (sourcePainting.id === work.id) return { ok: false, reason: 'self' }; await insertLegacyPaintingEdge(pool, work.id, sourcePainting.id, mergedEdge); const inserted = await insertInfluenceSource( pool, work.id, 'painting', { source_painting_id: sourcePainting.id }, mergedEdge, work.year ); return { ok: true, inserted, label: `${sourceRef.artist} / ${sourceRef.title}` }; } if (type === 'artist') { const artistId = await ensureArtist(pool, sourceRef, movementsByName); if (!artistId) return { ok: false, reason: 'unresolved artist' }; if (artistId === work.artist_id) return { ok: false, reason: 'self artist' }; const inserted = await insertInfluenceSource( pool, work.id, 'artist', { source_artist_id: artistId }, mergedEdge, work.year ); return { ok: true, inserted, label: sourceRef.artist }; } if (type === 'movement') { const movementId = await resolveMovement(pool, sourceRef.movement, movementsByName); if (!movementId) return { ok: false, reason: `unresolved movement: ${sourceRef.movement}` }; const inserted = await insertInfluenceSource( pool, work.id, 'movement', { source_movement_id: movementId }, mergedEdge, work.year ); return { ok: true, inserted, label: sourceRef.movement }; } return { ok: false, reason: `unknown type: ${type}` }; } module.exports = { normalizeArtist, normalizeInfluencedBy, loadMovements, resolveWorkRef, resolveAndInsertSource, findArtist, findPainting, resolveMovement, periodFields, };