Support artist and movement influence links with web discovery, a developer checkup table with gallery/detail thumbnails, and debug image search with fix-it workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
308 lines
9.3 KiB
JavaScript
308 lines
9.3 KiB
JavaScript
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,
|
|
};
|