Files
Art-gallery/scripts/influence-resolver.js
T
Danila KhodjaefandCursor bf7db9b25e Add influence rework, image checkup, debug mode, and fetched paintings.
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>
2026-06-20 10:29:43 +03:00

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