Add curator Influences tool with import wizard, CRUD, and graph.
CSV/JSON/XLSX mapping wizard expands artist-level rows to all paintings, blocks duplicate file/data imports via content and payload hashes, and documents the workflow in influence-import.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
62d7ebbe6a
commit
48bd17e985
@@ -26,6 +26,7 @@ const {
|
||||
localizeInfluenceSources,
|
||||
} = require('./translation-service');
|
||||
const translationRoutes = require('./routes/translations');
|
||||
const influenceRoutes = require('./routes/influences');
|
||||
const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
|
||||
|
||||
const app = express();
|
||||
@@ -43,6 +44,7 @@ app.use(express.json({ limit: '20mb' }));
|
||||
app.use(createSessionMiddleware());
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/translations', translationRoutes);
|
||||
app.use('/api/influences', influenceRoutes);
|
||||
app.use(
|
||||
'/images',
|
||||
express.static(IMAGE_DIR, {
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
/**
|
||||
* Parse CSV/JSON/XLSX influence files, map columns, resolve entities, expand
|
||||
* artist-level rows to all paintings, preview and commit into painting_influence_sources.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const XLSX = require('xlsx');
|
||||
const { parse: parseCsv } = require('csv-parse/sync');
|
||||
const pool = require('./db');
|
||||
const { normalizeArtist, loadMovements } = require('../scripts/influence-resolver');
|
||||
|
||||
function hashBuffer(buffer) {
|
||||
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
/** Stable fingerprint of row content (ignores filename / column order noise). */
|
||||
function hashImportPayload(rows, mapping) {
|
||||
const normalized = (rows || []).map((row) => {
|
||||
const mapped = applyMapping(row, mapping || {});
|
||||
return [
|
||||
mapped.subject_artist,
|
||||
mapped.subject_painting,
|
||||
mapped.influenced_by,
|
||||
mapped.influenced,
|
||||
mapped.notes,
|
||||
mapped.reference,
|
||||
mapped.source_url,
|
||||
].map((v) => String(v || '').trim().toLowerCase().replace(/\s+/g, ' '));
|
||||
});
|
||||
const canonical = JSON.stringify(normalized);
|
||||
return crypto.createHash('sha256').update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
async function findPriorImport({ contentHash, payloadHash }) {
|
||||
if (!contentHash && !payloadHash) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT l.id, l.created_at, l.details, u.username
|
||||
FROM curator_audit_log l
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
WHERE l.action = 'influence.import'
|
||||
AND (
|
||||
($1::text IS NOT NULL AND l.details->>'contentHash' = $1)
|
||||
OR ($2::text IS NOT NULL AND l.details->>'payloadHash' = $2)
|
||||
)
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 1`,
|
||||
[contentHash || null, payloadHash || null],
|
||||
);
|
||||
if (!rows[0]) return null;
|
||||
const d = rows[0].details || {};
|
||||
return {
|
||||
importedAt: rows[0].created_at,
|
||||
username: rows[0].username || null,
|
||||
fileName: d.fileName || null,
|
||||
inserted: d.inserted ?? null,
|
||||
contentHash: d.contentHash || null,
|
||||
payloadHash: d.payloadHash || null,
|
||||
match:
|
||||
contentHash && d.contentHash === contentHash
|
||||
? 'file'
|
||||
: payloadHash && d.payloadHash === payloadHash
|
||||
? 'data'
|
||||
: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
async function insertLegacy(poolClient, workId, sourcePaintingId, edge) {
|
||||
await poolClient.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 insertSource(poolClient, workId, sourceType, sourceIds, edge, workYear) {
|
||||
const result = await poolClient.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,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
edge.notes || null,
|
||||
edge.source || null,
|
||||
edge.aspects || null,
|
||||
edge.quote || null,
|
||||
edge.source_author || null,
|
||||
edge.source_url || null,
|
||||
edge.discovered_via || 'import-wizard',
|
||||
edge.confidence || 'curated',
|
||||
],
|
||||
);
|
||||
return result.rowCount > 0 ? result.rows[0].id : null;
|
||||
}
|
||||
|
||||
const COLUMN_ROLES = [
|
||||
'subject_artist',
|
||||
'subject_painting',
|
||||
'influenced_by',
|
||||
'influenced',
|
||||
'notes',
|
||||
'reference',
|
||||
'source_url',
|
||||
'ignore',
|
||||
];
|
||||
|
||||
const PRESETS = {
|
||||
web_sources: {
|
||||
id: 'web_sources',
|
||||
label: 'Web sources (Artist / Painting / Influenced by / Influenced / Reference)',
|
||||
mapping: {
|
||||
Artist: 'subject_artist',
|
||||
Painting: 'subject_painting',
|
||||
'Influenced by': 'influenced_by',
|
||||
Influenced: 'influenced',
|
||||
'Reference (source + link)': 'reference',
|
||||
},
|
||||
},
|
||||
story_of_art: {
|
||||
id: 'story_of_art',
|
||||
label: 'Story of Art / Title Case influences',
|
||||
mapping: {
|
||||
Artist: 'subject_artist',
|
||||
Painting: 'subject_painting',
|
||||
'Influenced by': 'influenced_by',
|
||||
Influenced: 'influenced',
|
||||
'Reference (chapter + context in text)': 'reference',
|
||||
Reference: 'reference',
|
||||
},
|
||||
},
|
||||
art_influences: {
|
||||
id: 'art_influences',
|
||||
label: 'Art influences (snake_case)',
|
||||
mapping: {
|
||||
artist: 'subject_artist',
|
||||
painting: 'subject_painting',
|
||||
influenced_by: 'influenced_by',
|
||||
influenced: 'influenced',
|
||||
reference: 'reference',
|
||||
},
|
||||
},
|
||||
custom: {
|
||||
id: 'custom',
|
||||
label: 'Custom mapping',
|
||||
mapping: {},
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeHeader(h) {
|
||||
return String(h || '')
|
||||
.replace(/^\uFEFF/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function suggestPreset(columns) {
|
||||
const set = new Set(columns.map((c) => c.toLowerCase()));
|
||||
if (set.has('influenced_by') && set.has('artist')) return 'art_influences';
|
||||
if (set.has('influenced by') && set.has('artist')) {
|
||||
if ([...columns].some((c) => /chapter/i.test(c))) return 'story_of_art';
|
||||
return 'web_sources';
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
function autoMapColumns(columns) {
|
||||
const mapping = {};
|
||||
for (const col of columns) {
|
||||
const lower = col.toLowerCase();
|
||||
if (lower === 'artist' || lower === 'subject_artist') mapping[col] = 'subject_artist';
|
||||
else if (lower === 'painting' || lower === 'subject_painting' || lower === 'work') mapping[col] = 'subject_painting';
|
||||
else if (lower === 'influenced by' || lower === 'influenced_by' || lower === 'influencedby') mapping[col] = 'influenced_by';
|
||||
else if (lower === 'influenced' || lower === 'influenced_on' || lower === 'influencedon') mapping[col] = 'influenced';
|
||||
else if (lower === 'notes') mapping[col] = 'notes';
|
||||
else if (lower.startsWith('reference') || lower === 'source') mapping[col] = 'reference';
|
||||
else if (lower === 'source_url' || lower === 'url' || lower === 'link') mapping[col] = 'source_url';
|
||||
else mapping[col] = 'ignore';
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function extractUrls(text) {
|
||||
if (!text) return [];
|
||||
const matches = String(text).match(/https?:\/\/[^\s;]+/gi) || [];
|
||||
return matches.map((u) => u.replace(/[),.;]+$/, ''));
|
||||
}
|
||||
|
||||
function splitTokens(cell) {
|
||||
if (cell == null || cell === '') return [];
|
||||
const text = String(cell).trim();
|
||||
if (!text || text.toLowerCase() === 'null' || text === 'None') return [];
|
||||
// Prefer semicolon splits; also split on commas when not inside parentheses
|
||||
const parts = text
|
||||
.split(/;/)
|
||||
.flatMap((chunk) => {
|
||||
const c = chunk.trim();
|
||||
if (!c) return [];
|
||||
// If many commas and no long phrases, split; else keep as one token and also try comma split for name lists
|
||||
if (c.includes(',') && !/\([^)]*,/.test(c)) {
|
||||
return c.split(',').map((x) => x.trim()).filter(Boolean);
|
||||
}
|
||||
return [c];
|
||||
})
|
||||
.map((t) => t.replace(/\s+/g, ' ').trim())
|
||||
.filter((t) => t.length > 1);
|
||||
return [...new Set(parts)];
|
||||
}
|
||||
|
||||
function parseBuffer(buffer, filename, options = {}) {
|
||||
const name = (filename || '').toLowerCase();
|
||||
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
||||
|
||||
if (ext === '.json' || (buffer[0] === 0x7b || buffer[0] === 0x5b)) {
|
||||
const text = buffer.toString('utf8');
|
||||
const data = JSON.parse(text);
|
||||
let rows;
|
||||
let sheets = null;
|
||||
if (Array.isArray(data)) {
|
||||
rows = data;
|
||||
} else if (data && Array.isArray(data.rows)) {
|
||||
rows = data.rows;
|
||||
} else if (data && typeof data === 'object') {
|
||||
sheets = Object.keys(data).filter((k) => Array.isArray(data[k]));
|
||||
const key = options.sheet || sheets[0];
|
||||
rows = data[key] || [];
|
||||
} else {
|
||||
throw new Error('JSON must be an array of objects or { rows: [...] }');
|
||||
}
|
||||
const columns = rows.length ? Object.keys(rows[0]).map(normalizeHeader) : [];
|
||||
return {
|
||||
format: 'json',
|
||||
sheets,
|
||||
sheet: options.sheet || (sheets && sheets[0]) || null,
|
||||
columns,
|
||||
rows: rows.map(normalizeRowKeys),
|
||||
sampleRows: rows.slice(0, 5).map(normalizeRowKeys),
|
||||
rowCount: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (ext === '.xlsx' || ext === '.xls' || buffer[0] === 0x50) {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer', cellDates: false });
|
||||
const sheets = workbook.SheetNames;
|
||||
const sheetName = options.sheet && sheets.includes(options.sheet) ? options.sheet : sheets[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { defval: '', raw: false });
|
||||
const columns = rows.length ? Object.keys(rows[0]).map(normalizeHeader) : [];
|
||||
const normalized = rows.map(normalizeRowKeys);
|
||||
return {
|
||||
format: 'xlsx',
|
||||
sheets,
|
||||
sheet: sheetName,
|
||||
columns,
|
||||
rows: normalized,
|
||||
sampleRows: normalized.slice(0, 5),
|
||||
rowCount: normalized.length,
|
||||
};
|
||||
}
|
||||
|
||||
// CSV default
|
||||
const text = buffer.toString('utf8');
|
||||
const records = parseCsv(text, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
relax_column_count: true,
|
||||
bom: true,
|
||||
trim: true,
|
||||
});
|
||||
const columns = records.length ? Object.keys(records[0]).map(normalizeHeader) : [];
|
||||
const normalized = records.map(normalizeRowKeys);
|
||||
return {
|
||||
format: 'csv',
|
||||
sheets: null,
|
||||
sheet: null,
|
||||
columns,
|
||||
rows: normalized,
|
||||
sampleRows: normalized.slice(0, 5),
|
||||
rowCount: normalized.length,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRowKeys(row) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(row)) {
|
||||
out[normalizeHeader(k)] = v == null ? '' : String(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyMapping(row, mapping) {
|
||||
const mapped = {
|
||||
subject_artist: '',
|
||||
subject_painting: '',
|
||||
influenced_by: '',
|
||||
influenced: '',
|
||||
notes: '',
|
||||
reference: '',
|
||||
source_url: '',
|
||||
};
|
||||
for (const [col, role] of Object.entries(mapping || {})) {
|
||||
if (!role || role === 'ignore') continue;
|
||||
if (!(role in mapped)) continue;
|
||||
const val = row[col];
|
||||
if (val == null || val === '') continue;
|
||||
mapped[role] = mapped[role] ? `${mapped[role]}; ${val}` : String(val);
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async function buildLookupCaches() {
|
||||
const movementsByName = await loadMovements(pool);
|
||||
const { rows: artists } = await pool.query('SELECT id, name FROM artists ORDER BY name');
|
||||
const { rows: paintings } = await pool.query(
|
||||
`SELECT p.id, p.title, p.year, p.artist_id, a.name AS artist_name
|
||||
FROM paintings p JOIN artists a ON a.id = p.artist_id`,
|
||||
);
|
||||
const { rows: movements } = await pool.query('SELECT id, name FROM art_movements ORDER BY name');
|
||||
|
||||
const artistsByLower = new Map();
|
||||
for (const a of artists) {
|
||||
artistsByLower.set(a.name.toLowerCase(), a);
|
||||
const canon = normalizeArtist(a.name);
|
||||
if (canon.toLowerCase() !== a.name.toLowerCase()) {
|
||||
artistsByLower.set(canon.toLowerCase(), a);
|
||||
}
|
||||
}
|
||||
|
||||
const paintingsByArtist = new Map();
|
||||
for (const p of paintings) {
|
||||
if (!paintingsByArtist.has(p.artist_id)) paintingsByArtist.set(p.artist_id, []);
|
||||
paintingsByArtist.get(p.artist_id).push(p);
|
||||
}
|
||||
|
||||
return { movementsByName, artists, artistsByLower, paintings, paintingsByArtist, movements };
|
||||
}
|
||||
|
||||
function resolveToken(token, caches, subjectArtistId) {
|
||||
const raw = token.trim();
|
||||
if (!raw) return { status: 'empty', token: raw };
|
||||
|
||||
const artistHit = caches.artistsByLower.get(raw.toLowerCase())
|
||||
|| caches.artistsByLower.get(normalizeArtist(raw).toLowerCase());
|
||||
if (artistHit) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
token: raw,
|
||||
sourceType: 'artist',
|
||||
sourceArtistId: artistHit.id,
|
||||
label: artistHit.name,
|
||||
};
|
||||
}
|
||||
|
||||
// Also try stripping parenthetical notes: "Parrhasius (their contest)"
|
||||
const bare = raw.replace(/\s*\([^)]*\)\s*/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (bare && bare.toLowerCase() !== raw.toLowerCase()) {
|
||||
const bareArtist = caches.artistsByLower.get(bare.toLowerCase())
|
||||
|| caches.artistsByLower.get(normalizeArtist(bare).toLowerCase());
|
||||
if (bareArtist) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
token: raw,
|
||||
sourceType: 'artist',
|
||||
sourceArtistId: bareArtist.id,
|
||||
label: bareArtist.name,
|
||||
};
|
||||
}
|
||||
const bareMovementId = caches.movementsByName.get(bare.toLowerCase());
|
||||
if (bareMovementId) {
|
||||
const mov = caches.movements.find((m) => m.id === bareMovementId);
|
||||
return {
|
||||
status: 'resolved',
|
||||
token: raw,
|
||||
sourceType: 'movement',
|
||||
sourceMovementId: bareMovementId,
|
||||
label: mov?.name || bare,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const movementId = caches.movementsByName.get(raw.toLowerCase());
|
||||
if (movementId) {
|
||||
const mov = caches.movements.find((m) => m.id === movementId);
|
||||
return {
|
||||
status: 'resolved',
|
||||
token: raw,
|
||||
sourceType: 'movement',
|
||||
sourceMovementId: movementId,
|
||||
label: mov?.name || raw,
|
||||
};
|
||||
}
|
||||
|
||||
// Painting matches only for short title-like tokens (avoid free-text prose false hits)
|
||||
const wordCount = raw.split(/\s+/).length;
|
||||
if (wordCount <= 8) {
|
||||
if (subjectArtistId) {
|
||||
const list = caches.paintingsByArtist.get(subjectArtistId) || [];
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
for (const p of list) {
|
||||
const score = scoreTitle(p.title, raw);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
if (best && bestScore >= 2) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
token: raw,
|
||||
sourceType: 'painting',
|
||||
sourcePaintingId: best.id,
|
||||
label: `${best.artist_name} — ${best.title}`,
|
||||
};
|
||||
}
|
||||
// Exact-ish normalized equality
|
||||
const normRaw = normalizeLoose(raw);
|
||||
const exact = list.find((p) => normalizeLoose(p.title) === normRaw);
|
||||
if (exact) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
token: raw,
|
||||
sourceType: 'painting',
|
||||
sourcePaintingId: exact.id,
|
||||
label: `${exact.artist_name} — ${exact.title}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const normRaw = normalizeLoose(raw);
|
||||
const titleHits = caches.paintings.filter((p) => normalizeLoose(p.title) === normRaw);
|
||||
if (titleHits.length === 1) {
|
||||
const p = titleHits[0];
|
||||
return {
|
||||
status: 'resolved',
|
||||
token: raw,
|
||||
sourceType: 'painting',
|
||||
sourcePaintingId: p.id,
|
||||
label: `${p.artist_name} — ${p.title}`,
|
||||
};
|
||||
}
|
||||
if (titleHits.length > 1) {
|
||||
return {
|
||||
status: 'ambiguous',
|
||||
token: raw,
|
||||
candidates: titleHits.slice(0, 8).map((p) => ({
|
||||
sourceType: 'painting',
|
||||
sourcePaintingId: p.id,
|
||||
label: `${p.artist_name} — ${p.title}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'unresolved', token: raw };
|
||||
}
|
||||
|
||||
function normalizeLoose(s) {
|
||||
return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function scoreTitle(dbTitle, hint) {
|
||||
const normDb = normalizeLoose(dbTitle);
|
||||
const keywords = normalizeLoose(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 edgeKey(paintingId, sourceType, sourcePaintingId, sourceArtistId, sourceMovementId) {
|
||||
return [
|
||||
paintingId,
|
||||
sourceType,
|
||||
sourcePaintingId || 0,
|
||||
sourceArtistId || 0,
|
||||
sourceMovementId || 0,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build preview proposals from mapped file rows.
|
||||
* Artist-level: expand to all paintings of subject (influenced_by) or target (influenced).
|
||||
*/
|
||||
async function buildPreview(rows, mapping, options = {}) {
|
||||
const caches = await buildLookupCaches();
|
||||
const { rows: existing } = await pool.query(
|
||||
`SELECT painting_id, source_type, source_painting_id, source_artist_id, source_movement_id
|
||||
FROM painting_influence_sources`,
|
||||
);
|
||||
const existingKeys = new Set(
|
||||
existing.map((e) =>
|
||||
edgeKey(e.painting_id, e.source_type, e.source_painting_id, e.source_artist_id, e.source_movement_id),
|
||||
),
|
||||
);
|
||||
|
||||
const proposals = [];
|
||||
const warnings = [];
|
||||
let errors = 0;
|
||||
|
||||
rows.forEach((row, rowIndex) => {
|
||||
const mapped = applyMapping(row, mapping);
|
||||
const subjectName = mapped.subject_artist.trim();
|
||||
if (!subjectName) {
|
||||
warnings.push({ rowIndex, message: 'Missing subject artist' });
|
||||
errors += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const subjectArtist = caches.artistsByLower.get(subjectName.toLowerCase())
|
||||
|| caches.artistsByLower.get(normalizeArtist(subjectName).toLowerCase());
|
||||
if (!subjectArtist) {
|
||||
warnings.push({ rowIndex, message: `Unresolved subject artist: ${subjectName}`, token: subjectName });
|
||||
errors += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let subjectPaintings = caches.paintingsByArtist.get(subjectArtist.id) || [];
|
||||
if (mapped.subject_painting.trim()) {
|
||||
const hint = mapped.subject_painting.trim();
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
for (const p of subjectPaintings) {
|
||||
const s = scoreTitle(p.title, hint);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
// Plan decision 1A: still expand to ALL paintings; painting column is contextual only.
|
||||
// Keep note of matched work in metadata.
|
||||
if (!best) {
|
||||
warnings.push({
|
||||
rowIndex,
|
||||
message: `Painting hint not matched (still expanding to all works): ${hint}`,
|
||||
token: hint,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!subjectPaintings.length) {
|
||||
warnings.push({ rowIndex, message: `Artist has no paintings: ${subjectArtist.name}` });
|
||||
errors += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const urls = extractUrls(mapped.source_url || mapped.reference);
|
||||
const notesParts = [mapped.notes, mapped.reference].filter(Boolean);
|
||||
const edgeMeta = {
|
||||
notes: notesParts.join(' | ').slice(0, 4000) || null,
|
||||
source: options.sourceLabel || 'import-wizard',
|
||||
source_url: urls[0] || null,
|
||||
confidence: 'curated',
|
||||
discovered_via: 'import-wizard',
|
||||
};
|
||||
|
||||
const byTokens = splitTokens(mapped.influenced_by);
|
||||
for (const token of byTokens) {
|
||||
const resolved = resolveToken(token, caches, subjectArtist.id);
|
||||
if (resolved.status !== 'resolved') {
|
||||
warnings.push({
|
||||
rowIndex,
|
||||
message: `${resolved.status}: ${token}`,
|
||||
token,
|
||||
direction: 'influenced_by',
|
||||
candidates: resolved.candidates,
|
||||
});
|
||||
if (resolved.status === 'unresolved' || resolved.status === 'ambiguous') {
|
||||
/* counted in warnings */
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const p of subjectPaintings) {
|
||||
const prop = makeProposal({
|
||||
rowIndex,
|
||||
direction: 'influenced_by',
|
||||
paintingId: p.id,
|
||||
paintingTitle: p.title,
|
||||
artistId: subjectArtist.id,
|
||||
artistName: subjectArtist.name,
|
||||
resolved,
|
||||
edgeMeta,
|
||||
existingKeys,
|
||||
});
|
||||
proposals.push(prop);
|
||||
}
|
||||
}
|
||||
|
||||
const onTokens = splitTokens(mapped.influenced);
|
||||
for (const token of onTokens) {
|
||||
const resolved = resolveToken(token, caches, subjectArtist.id);
|
||||
if (resolved.status !== 'resolved') {
|
||||
warnings.push({
|
||||
rowIndex,
|
||||
message: `${resolved.status}: ${token}`,
|
||||
token,
|
||||
direction: 'influenced',
|
||||
candidates: resolved.candidates,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolved.sourceType === 'artist') {
|
||||
// Reverse: subject artist is the source on the target artist's paintings
|
||||
const targetPaintings = caches.paintingsByArtist.get(resolved.sourceArtistId) || [];
|
||||
if (!targetPaintings.length) {
|
||||
warnings.push({
|
||||
rowIndex,
|
||||
message: `Influenced artist has no paintings: ${resolved.label}`,
|
||||
token,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const reverseResolved = {
|
||||
status: 'resolved',
|
||||
token: subjectArtist.name,
|
||||
sourceType: 'artist',
|
||||
sourceArtistId: subjectArtist.id,
|
||||
label: subjectArtist.name,
|
||||
};
|
||||
const reverseMeta = {
|
||||
...edgeMeta,
|
||||
notes: [
|
||||
edgeMeta.notes,
|
||||
`Reverse link: ${subjectArtist.name} listed as influence on ${resolved.label}.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
};
|
||||
for (const p of targetPaintings) {
|
||||
const prop = makeProposal({
|
||||
rowIndex,
|
||||
direction: 'influenced',
|
||||
paintingId: p.id,
|
||||
paintingTitle: p.title,
|
||||
artistId: resolved.sourceArtistId,
|
||||
artistName: resolved.label,
|
||||
resolved: reverseResolved,
|
||||
edgeMeta: reverseMeta,
|
||||
existingKeys,
|
||||
});
|
||||
proposals.push(prop);
|
||||
}
|
||||
} else {
|
||||
// Non-artist "influenced" targets: attach as sources on subject's paintings (weaker semantics)
|
||||
for (const p of subjectPaintings) {
|
||||
const prop = makeProposal({
|
||||
rowIndex,
|
||||
direction: 'influenced',
|
||||
paintingId: p.id,
|
||||
paintingTitle: p.title,
|
||||
artistId: subjectArtist.id,
|
||||
artistName: subjectArtist.name,
|
||||
resolved,
|
||||
edgeMeta: {
|
||||
...edgeMeta,
|
||||
notes: [edgeMeta.notes, `Listed under Influenced (non-artist target).`].filter(Boolean).join(' '),
|
||||
},
|
||||
existingKeys,
|
||||
});
|
||||
proposals.push(prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Deduplicate proposals by edge key (keep first)
|
||||
const seen = new Set();
|
||||
const deduped = [];
|
||||
for (const p of proposals) {
|
||||
if (seen.has(p.edgeKey)) {
|
||||
p.action = 'skip';
|
||||
p.reason = p.reason || 'duplicate in file';
|
||||
continue;
|
||||
}
|
||||
seen.add(p.edgeKey);
|
||||
deduped.push(p);
|
||||
}
|
||||
|
||||
return {
|
||||
proposals: deduped,
|
||||
warnings,
|
||||
counts: {
|
||||
rows: rows.length,
|
||||
proposals: deduped.length,
|
||||
willCreate: deduped.filter((p) => p.action === 'create').length,
|
||||
willSkip: deduped.filter((p) => p.action === 'skip').length,
|
||||
errors,
|
||||
unresolvedTokens: warnings.filter((w) => w.token).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeProposal({
|
||||
rowIndex,
|
||||
direction,
|
||||
paintingId,
|
||||
paintingTitle,
|
||||
artistId,
|
||||
artistName,
|
||||
resolved,
|
||||
edgeMeta,
|
||||
existingKeys,
|
||||
}) {
|
||||
const sourceType = resolved.sourceType;
|
||||
const sourcePaintingId = resolved.sourcePaintingId || null;
|
||||
const sourceArtistId = resolved.sourceArtistId || null;
|
||||
const sourceMovementId = resolved.sourceMovementId || null;
|
||||
|
||||
if (sourceType === 'painting' && sourcePaintingId === paintingId) {
|
||||
return {
|
||||
rowIndex,
|
||||
direction,
|
||||
paintingId,
|
||||
paintingTitle,
|
||||
artistId,
|
||||
artistName,
|
||||
sourceType,
|
||||
sourcePaintingId,
|
||||
sourceArtistId,
|
||||
sourceMovementId,
|
||||
sourceLabel: resolved.label,
|
||||
token: resolved.token,
|
||||
notes: edgeMeta.notes,
|
||||
source: edgeMeta.source,
|
||||
sourceUrl: edgeMeta.source_url,
|
||||
confidence: edgeMeta.confidence,
|
||||
discoveredVia: edgeMeta.discovered_via,
|
||||
edgeKey: edgeKey(paintingId, sourceType, sourcePaintingId, sourceArtistId, sourceMovementId),
|
||||
action: 'skip',
|
||||
reason: 'self painting',
|
||||
};
|
||||
}
|
||||
if (sourceType === 'artist' && sourceArtistId === artistId) {
|
||||
return {
|
||||
rowIndex,
|
||||
direction,
|
||||
paintingId,
|
||||
paintingTitle,
|
||||
artistId,
|
||||
artistName,
|
||||
sourceType,
|
||||
sourcePaintingId,
|
||||
sourceArtistId,
|
||||
sourceMovementId,
|
||||
sourceLabel: resolved.label,
|
||||
token: resolved.token,
|
||||
notes: edgeMeta.notes,
|
||||
source: edgeMeta.source,
|
||||
sourceUrl: edgeMeta.source_url,
|
||||
confidence: edgeMeta.confidence,
|
||||
discoveredVia: edgeMeta.discovered_via,
|
||||
edgeKey: edgeKey(paintingId, sourceType, sourcePaintingId, sourceArtistId, sourceMovementId),
|
||||
action: 'skip',
|
||||
reason: 'self artist',
|
||||
};
|
||||
}
|
||||
|
||||
const key = edgeKey(paintingId, sourceType, sourcePaintingId, sourceArtistId, sourceMovementId);
|
||||
const exists = existingKeys.has(key);
|
||||
return {
|
||||
rowIndex,
|
||||
direction,
|
||||
paintingId,
|
||||
paintingTitle,
|
||||
artistId,
|
||||
artistName,
|
||||
sourceType,
|
||||
sourcePaintingId,
|
||||
sourceArtistId,
|
||||
sourceMovementId,
|
||||
sourceLabel: resolved.label,
|
||||
token: resolved.token,
|
||||
notes: edgeMeta.notes,
|
||||
source: edgeMeta.source,
|
||||
sourceUrl: edgeMeta.source_url,
|
||||
confidence: edgeMeta.confidence,
|
||||
discoveredVia: edgeMeta.discovered_via,
|
||||
edgeKey: key,
|
||||
action: exists ? 'skip' : 'create',
|
||||
reason: exists ? 'already in database' : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function commitProposals(proposals, { userId, req, fileName } = {}) {
|
||||
const toCreate = (proposals || []).filter((p) => p.action === 'create');
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const p of toCreate) {
|
||||
const edge = {
|
||||
notes: p.notes,
|
||||
source: p.source,
|
||||
source_url: p.sourceUrl,
|
||||
confidence: p.confidence || 'curated',
|
||||
discovered_via: p.discoveredVia || 'import-wizard',
|
||||
};
|
||||
const sourceIds = {
|
||||
source_painting_id: p.sourcePaintingId,
|
||||
source_artist_id: p.sourceArtistId,
|
||||
source_movement_id: p.sourceMovementId,
|
||||
};
|
||||
if (p.sourceType === 'painting' && p.sourcePaintingId) {
|
||||
await insertLegacy(client, p.paintingId, p.sourcePaintingId, edge);
|
||||
}
|
||||
const id = await insertSource(client, p.paintingId, p.sourceType, sourceIds, edge, null);
|
||||
if (id) inserted += 1;
|
||||
else skipped += 1;
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return { inserted, skipped, attempted: toCreate.length, fileName: fileName || null };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COLUMN_ROLES,
|
||||
PRESETS,
|
||||
parseBuffer,
|
||||
suggestPreset,
|
||||
autoMapColumns,
|
||||
applyMapping,
|
||||
buildPreview,
|
||||
commitProposals,
|
||||
splitTokens,
|
||||
normalizeHeader,
|
||||
hashBuffer,
|
||||
hashImportPayload,
|
||||
findPriorImport,
|
||||
};
|
||||
@@ -0,0 +1,643 @@
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireCurator } = require('../middleware/auth');
|
||||
const { logCuratorAction } = require('../audit-log');
|
||||
const {
|
||||
COLUMN_ROLES,
|
||||
PRESETS,
|
||||
parseBuffer,
|
||||
suggestPreset,
|
||||
autoMapColumns,
|
||||
buildPreview,
|
||||
commitProposals,
|
||||
hashBuffer,
|
||||
hashImportPayload,
|
||||
findPriorImport,
|
||||
} = require('../influence-import-service');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function parseId(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
||||
}
|
||||
|
||||
router.get('/presets', requireCurator, (_req, res) => {
|
||||
res.json({
|
||||
roles: COLUMN_ROLES,
|
||||
presets: Object.values(PRESETS).map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
mapping: p.mapping,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const artistId = parseId(req.query.artistId);
|
||||
const paintingId = parseId(req.query.paintingId);
|
||||
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
||||
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
|
||||
const offset = Math.max(0, Number(req.query.offset) || 0);
|
||||
|
||||
const params = [];
|
||||
const where = [];
|
||||
if (artistId) {
|
||||
params.push(artistId);
|
||||
where.push(`a.id = $${params.length}`);
|
||||
}
|
||||
if (paintingId) {
|
||||
params.push(paintingId);
|
||||
where.push(`pis.painting_id = $${params.length}`);
|
||||
}
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
const i = params.length;
|
||||
where.push(`(
|
||||
p.title ILIKE $${i} OR a.name ILIKE $${i}
|
||||
OR sa.name ILIKE $${i} OR sp.title ILIKE $${i} OR m.name ILIKE $${i}
|
||||
OR pis.notes ILIKE $${i} OR pis.source ILIKE $${i}
|
||||
)`);
|
||||
}
|
||||
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
params.push(limit);
|
||||
const limitIdx = params.length;
|
||||
params.push(offset);
|
||||
const offsetIdx = params.length;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT pis.id, pis.painting_id, pis.source_type,
|
||||
pis.source_painting_id, pis.source_artist_id, pis.source_movement_id,
|
||||
pis.notes, pis.source, pis.source_url, pis.aspects, pis.quote,
|
||||
pis.confidence, pis.discovered_via, pis.updated_at,
|
||||
p.title AS painting_title, p.year AS painting_year,
|
||||
a.id AS artist_id, a.name AS artist_name,
|
||||
sp.title AS source_painting_title,
|
||||
sa.name AS source_artist_name,
|
||||
m.name AS source_movement_name
|
||||
FROM painting_influence_sources pis
|
||||
JOIN paintings p ON p.id = pis.painting_id
|
||||
JOIN artists a ON a.id = p.artist_id
|
||||
LEFT JOIN paintings sp ON sp.id = pis.source_painting_id
|
||||
LEFT JOIN artists sa ON sa.id = pis.source_artist_id
|
||||
LEFT JOIN art_movements m ON m.id = pis.source_movement_id
|
||||
${whereSql}
|
||||
ORDER BY pis.id DESC
|
||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
||||
params,
|
||||
);
|
||||
|
||||
const countParams = params.slice(0, -2);
|
||||
const { rows: countRows } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n
|
||||
FROM painting_influence_sources pis
|
||||
JOIN paintings p ON p.id = pis.painting_id
|
||||
JOIN artists a ON a.id = p.artist_id
|
||||
LEFT JOIN paintings sp ON sp.id = pis.source_painting_id
|
||||
LEFT JOIN artists sa ON sa.id = pis.source_artist_id
|
||||
LEFT JOIN art_movements m ON m.id = pis.source_movement_id
|
||||
${whereSql}`,
|
||||
countParams,
|
||||
);
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
paintingId: r.painting_id,
|
||||
paintingTitle: r.painting_title,
|
||||
paintingYear: r.painting_year,
|
||||
artistId: r.artist_id,
|
||||
artistName: r.artist_name,
|
||||
sourceType: r.source_type,
|
||||
sourcePaintingId: r.source_painting_id,
|
||||
sourceArtistId: r.source_artist_id,
|
||||
sourceMovementId: r.source_movement_id,
|
||||
sourceLabel:
|
||||
r.source_type === 'painting'
|
||||
? r.source_painting_title
|
||||
: r.source_type === 'artist'
|
||||
? r.source_artist_name
|
||||
: r.source_movement_name,
|
||||
notes: r.notes,
|
||||
source: r.source,
|
||||
sourceUrl: r.source_url,
|
||||
aspects: r.aspects,
|
||||
quote: r.quote,
|
||||
confidence: r.confidence,
|
||||
discoveredVia: r.discovered_via,
|
||||
updatedAt: r.updated_at,
|
||||
}));
|
||||
|
||||
res.json({ items, total: countRows[0]?.n || 0, limit, offset });
|
||||
} catch (err) {
|
||||
console.error('Influences list error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to list influences' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/graph', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const artistId = parseId(req.query.artistId);
|
||||
const paintingId = parseId(req.query.paintingId);
|
||||
if (!artistId && !paintingId) {
|
||||
return res.status(400).json({ error: 'artistId or paintingId required' });
|
||||
}
|
||||
|
||||
let paintingIds = [];
|
||||
let focusArtistId = artistId;
|
||||
let focusLabel = '';
|
||||
|
||||
if (paintingId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.id, p.title, p.artist_id, a.name AS artist_name
|
||||
FROM paintings p JOIN artists a ON a.id = p.artist_id WHERE p.id = $1`,
|
||||
[paintingId],
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'Painting not found' });
|
||||
paintingIds = [paintingId];
|
||||
focusArtistId = rows[0].artist_id;
|
||||
focusLabel = `${rows[0].artist_name} — ${rows[0].title}`;
|
||||
} else {
|
||||
const { rows: artistRows } = await pool.query('SELECT id, name FROM artists WHERE id = $1', [artistId]);
|
||||
if (!artistRows[0]) return res.status(404).json({ error: 'Artist not found' });
|
||||
focusLabel = artistRows[0].name;
|
||||
const { rows: paints } = await pool.query('SELECT id FROM paintings WHERE artist_id = $1', [artistId]);
|
||||
paintingIds = paints.map((p) => p.id);
|
||||
}
|
||||
|
||||
if (!paintingIds.length) {
|
||||
return res.json({ focus: { artistId: focusArtistId, label: focusLabel }, nodes: [], edges: [] });
|
||||
}
|
||||
|
||||
const { rows: outgoing } = await pool.query(
|
||||
`SELECT pis.id, pis.painting_id, pis.source_type,
|
||||
pis.source_painting_id, pis.source_artist_id, pis.source_movement_id,
|
||||
p.title AS painting_title, a.name AS artist_name,
|
||||
sp.title AS source_painting_title, spa.name AS source_painting_artist,
|
||||
sa.name AS source_artist_name, m.name AS source_movement_name
|
||||
FROM painting_influence_sources pis
|
||||
JOIN paintings p ON p.id = pis.painting_id
|
||||
JOIN artists a ON a.id = p.artist_id
|
||||
LEFT JOIN paintings sp ON sp.id = pis.source_painting_id
|
||||
LEFT JOIN artists spa ON spa.id = sp.artist_id
|
||||
LEFT JOIN artists sa ON sa.id = pis.source_artist_id
|
||||
LEFT JOIN art_movements m ON m.id = pis.source_movement_id
|
||||
WHERE pis.painting_id = ANY($1::int[])`,
|
||||
[paintingIds],
|
||||
);
|
||||
|
||||
const { rows: incoming } = await pool.query(
|
||||
`SELECT pis.id, pis.painting_id, pis.source_type,
|
||||
pis.source_painting_id, pis.source_artist_id, pis.source_movement_id,
|
||||
p.title AS painting_title, a.name AS artist_name, a.id AS subject_artist_id
|
||||
FROM painting_influence_sources pis
|
||||
JOIN paintings p ON p.id = pis.painting_id
|
||||
JOIN artists a ON a.id = p.artist_id
|
||||
WHERE (
|
||||
(pis.source_type = 'artist' AND pis.source_artist_id = $1)
|
||||
OR (pis.source_type = 'painting' AND pis.source_painting_id = ANY($2::int[]))
|
||||
)`,
|
||||
[focusArtistId, paintingIds],
|
||||
);
|
||||
|
||||
const nodes = new Map();
|
||||
const addNode = (id, type, label, meta = {}) => {
|
||||
if (!nodes.has(id)) nodes.set(id, { id, type, label, ...meta });
|
||||
};
|
||||
|
||||
addNode(`artist:${focusArtistId}`, 'artist', focusLabel, { artistId: focusArtistId, focus: true });
|
||||
|
||||
const edges = [];
|
||||
|
||||
for (const r of outgoing) {
|
||||
let targetId;
|
||||
let targetLabel;
|
||||
let targetType = r.source_type;
|
||||
if (r.source_type === 'artist') {
|
||||
targetId = `artist:${r.source_artist_id}`;
|
||||
targetLabel = r.source_artist_name;
|
||||
addNode(targetId, 'artist', targetLabel, { artistId: r.source_artist_id });
|
||||
} else if (r.source_type === 'movement') {
|
||||
targetId = `movement:${r.source_movement_id}`;
|
||||
targetLabel = r.source_movement_name;
|
||||
addNode(targetId, 'movement', targetLabel, { movementId: r.source_movement_id });
|
||||
} else {
|
||||
targetId = `painting:${r.source_painting_id}`;
|
||||
targetLabel = `${r.source_painting_artist} — ${r.source_painting_title}`;
|
||||
addNode(targetId, 'painting', targetLabel, { paintingId: r.source_painting_id });
|
||||
}
|
||||
edges.push({
|
||||
id: r.id,
|
||||
from: `artist:${focusArtistId}`,
|
||||
to: targetId,
|
||||
direction: 'influenced_by',
|
||||
label: r.painting_title,
|
||||
});
|
||||
}
|
||||
|
||||
for (const r of incoming) {
|
||||
const fromId = `artist:${r.subject_artist_id}`;
|
||||
addNode(fromId, 'artist', r.artist_name, { artistId: r.subject_artist_id });
|
||||
edges.push({
|
||||
id: r.id,
|
||||
from: fromId,
|
||||
to: `artist:${focusArtistId}`,
|
||||
direction: 'influenced',
|
||||
label: r.painting_title,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
focus: { artistId: focusArtistId, paintingId: paintingId || null, label: focusLabel },
|
||||
nodes: [...nodes.values()],
|
||||
edges,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Influences graph error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to load influence graph' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
paintingId,
|
||||
sourceType,
|
||||
sourcePaintingId,
|
||||
sourceArtistId,
|
||||
sourceMovementId,
|
||||
notes,
|
||||
source,
|
||||
sourceUrl,
|
||||
aspects,
|
||||
quote,
|
||||
confidence,
|
||||
} = req.body || {};
|
||||
|
||||
const pid = parseId(paintingId);
|
||||
if (!pid) return res.status(400).json({ error: 'paintingId required' });
|
||||
if (!['painting', 'artist', 'movement'].includes(sourceType)) {
|
||||
return res.status(400).json({ error: 'sourceType must be painting, artist, or movement' });
|
||||
}
|
||||
|
||||
const sourceIds = {
|
||||
source_painting_id: sourceType === 'painting' ? parseId(sourcePaintingId) : null,
|
||||
source_artist_id: sourceType === 'artist' ? parseId(sourceArtistId) : null,
|
||||
source_movement_id: sourceType === 'movement' ? parseId(sourceMovementId) : null,
|
||||
};
|
||||
if (sourceType === 'painting' && !sourceIds.source_painting_id) {
|
||||
return res.status(400).json({ error: 'sourcePaintingId required' });
|
||||
}
|
||||
if (sourceType === 'artist' && !sourceIds.source_artist_id) {
|
||||
return res.status(400).json({ error: 'sourceArtistId required' });
|
||||
}
|
||||
if (sourceType === 'movement' && !sourceIds.source_movement_id) {
|
||||
return res.status(400).json({ error: 'sourceMovementId required' });
|
||||
}
|
||||
|
||||
const edge = {
|
||||
notes: notes || null,
|
||||
source: source || null,
|
||||
source_url: sourceUrl || null,
|
||||
aspects: aspects || null,
|
||||
quote: quote || null,
|
||||
confidence: confidence || 'curated',
|
||||
discovered_via: 'curator-ui',
|
||||
};
|
||||
|
||||
if (sourceType === 'painting') {
|
||||
await pool.query(
|
||||
`INSERT INTO painting_influences
|
||||
(painting_id, influenced_by_painting_id, notes, source, aspects, quote, source_url)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (painting_id, influenced_by_painting_id) DO NOTHING`,
|
||||
[pid, sourceIds.source_painting_id, edge.notes, edge.source, edge.aspects, edge.quote, edge.source_url],
|
||||
);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO painting_influence_sources (
|
||||
painting_id, source_type, source_painting_id, source_artist_id, source_movement_id,
|
||||
notes, source, aspects, quote, source_url, discovered_via, confidence
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id`,
|
||||
[
|
||||
pid,
|
||||
sourceType,
|
||||
sourceIds.source_painting_id,
|
||||
sourceIds.source_artist_id,
|
||||
sourceIds.source_movement_id,
|
||||
edge.notes,
|
||||
edge.source,
|
||||
edge.aspects,
|
||||
edge.quote,
|
||||
edge.source_url,
|
||||
edge.discovered_via,
|
||||
edge.confidence,
|
||||
],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) {
|
||||
return res.status(409).json({ error: 'Influence edge already exists' });
|
||||
}
|
||||
|
||||
await logCuratorAction({
|
||||
userId: req.curatorUser.id,
|
||||
action: 'influence.create',
|
||||
resourceType: 'influence_source',
|
||||
resourceId: result.rows[0].id,
|
||||
details: { paintingId: pid, sourceType, ...sourceIds },
|
||||
req,
|
||||
});
|
||||
|
||||
res.status(201).json({ id: result.rows[0].id });
|
||||
} catch (err) {
|
||||
console.error('Influence create error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to create influence' });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid id' });
|
||||
|
||||
const {
|
||||
notes,
|
||||
source,
|
||||
sourceUrl,
|
||||
aspects,
|
||||
quote,
|
||||
confidence,
|
||||
sourceType,
|
||||
sourcePaintingId,
|
||||
sourceArtistId,
|
||||
sourceMovementId,
|
||||
} = req.body || {};
|
||||
|
||||
const { rows: existing } = await pool.query(
|
||||
'SELECT * FROM painting_influence_sources WHERE id = $1',
|
||||
[id],
|
||||
);
|
||||
if (!existing[0]) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const nextType = sourceType || existing[0].source_type;
|
||||
const nextPainting =
|
||||
sourceType === 'painting'
|
||||
? parseId(sourcePaintingId)
|
||||
: sourceType
|
||||
? null
|
||||
: existing[0].source_painting_id;
|
||||
const nextArtist =
|
||||
sourceType === 'artist'
|
||||
? parseId(sourceArtistId)
|
||||
: sourceType
|
||||
? null
|
||||
: existing[0].source_artist_id;
|
||||
const nextMovement =
|
||||
sourceType === 'movement'
|
||||
? parseId(sourceMovementId)
|
||||
: sourceType
|
||||
? null
|
||||
: existing[0].source_movement_id;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE painting_influence_sources SET
|
||||
source_type = $2,
|
||||
source_painting_id = $3,
|
||||
source_artist_id = $4,
|
||||
source_movement_id = $5,
|
||||
notes = COALESCE($6, notes),
|
||||
source = COALESCE($7, source),
|
||||
source_url = COALESCE($8, source_url),
|
||||
aspects = COALESCE($9, aspects),
|
||||
quote = COALESCE($10, quote),
|
||||
confidence = COALESCE($11, confidence)
|
||||
WHERE id = $1
|
||||
RETURNING id`,
|
||||
[
|
||||
id,
|
||||
nextType,
|
||||
nextPainting,
|
||||
nextArtist,
|
||||
nextMovement,
|
||||
notes !== undefined ? notes : null,
|
||||
source !== undefined ? source : null,
|
||||
sourceUrl !== undefined ? sourceUrl : null,
|
||||
aspects !== undefined ? aspects : null,
|
||||
quote !== undefined ? quote : null,
|
||||
confidence !== undefined ? confidence : null,
|
||||
],
|
||||
);
|
||||
|
||||
await logCuratorAction({
|
||||
userId: req.curatorUser.id,
|
||||
action: 'influence.update',
|
||||
resourceType: 'influence_source',
|
||||
resourceId: id,
|
||||
details: { sourceType: nextType },
|
||||
req,
|
||||
});
|
||||
|
||||
res.json({ id: result.rows[0].id });
|
||||
} catch (err) {
|
||||
console.error('Influence update error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to update influence' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid id' });
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM painting_influence_sources WHERE id = $1',
|
||||
[id],
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'Not found' });
|
||||
const row = rows[0];
|
||||
|
||||
if (row.source_type === 'painting' && row.source_painting_id) {
|
||||
await pool.query(
|
||||
`DELETE FROM painting_influences
|
||||
WHERE painting_id = $1 AND influenced_by_painting_id = $2`,
|
||||
[row.painting_id, row.source_painting_id],
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM painting_influence_sources WHERE id = $1', [id]);
|
||||
|
||||
await logCuratorAction({
|
||||
userId: req.curatorUser.id,
|
||||
action: 'influence.delete',
|
||||
resourceType: 'influence_source',
|
||||
resourceId: id,
|
||||
details: {
|
||||
paintingId: row.painting_id,
|
||||
sourceType: row.source_type,
|
||||
},
|
||||
req,
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('Influence delete error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to delete influence' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/import/parse', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const { filename, sheet, contentBase64, content } = req.body || {};
|
||||
let buffer;
|
||||
if (typeof contentBase64 === 'string' && contentBase64.length) {
|
||||
buffer = Buffer.from(contentBase64, 'base64');
|
||||
} else if (typeof content === 'string' && content.length) {
|
||||
buffer = Buffer.from(content, 'utf8');
|
||||
} else {
|
||||
return res.status(400).json({ error: 'contentBase64 or content required' });
|
||||
}
|
||||
if (!buffer.length) return res.status(400).json({ error: 'Empty file' });
|
||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||
return res.status(413).json({ error: 'File too large (max 10 MB)' });
|
||||
}
|
||||
|
||||
const name = typeof filename === 'string' && filename ? filename : 'upload.csv';
|
||||
const sheetName = typeof sheet === 'string' ? sheet : undefined;
|
||||
|
||||
const parsed = parseBuffer(buffer, name, { sheet: sheetName });
|
||||
const contentHash = hashBuffer(buffer);
|
||||
const presetId = suggestPreset(parsed.columns);
|
||||
const preset = PRESETS[presetId] || PRESETS.custom;
|
||||
const mapping = Object.keys(preset.mapping).length
|
||||
? {
|
||||
...autoMapColumns(parsed.columns),
|
||||
...Object.fromEntries(
|
||||
Object.entries(preset.mapping).filter(([col]) => parsed.columns.includes(col)),
|
||||
),
|
||||
}
|
||||
: autoMapColumns(parsed.columns);
|
||||
|
||||
const includeAll = parsed.rowCount <= 2000;
|
||||
const rowsForHash = includeAll ? parsed.rows : parsed.rows;
|
||||
const payloadHash = hashImportPayload(rowsForHash || [], mapping);
|
||||
const priorImport = await findPriorImport({ contentHash, payloadHash });
|
||||
|
||||
res.json({
|
||||
filename: name,
|
||||
format: parsed.format,
|
||||
sheets: parsed.sheets,
|
||||
sheet: parsed.sheet,
|
||||
columns: parsed.columns,
|
||||
rowCount: parsed.rowCount,
|
||||
sampleRows: parsed.sampleRows,
|
||||
rows: includeAll ? parsed.rows : undefined,
|
||||
suggestedPreset: presetId,
|
||||
suggestedMapping: mapping,
|
||||
roles: COLUMN_ROLES,
|
||||
presets: Object.values(PRESETS).map((p) => ({ id: p.id, label: p.label, mapping: p.mapping })),
|
||||
contentHash,
|
||||
payloadHash,
|
||||
alreadyImported: Boolean(priorImport),
|
||||
priorImport,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Influence import parse error:', err.message);
|
||||
res.status(400).json({ error: err.message || 'Failed to parse file' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/import/preview', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const { rows, mapping, sourceLabel, contentHash, payloadHash } = req.body || {};
|
||||
if (!Array.isArray(rows) || !rows.length) {
|
||||
return res.status(400).json({ error: 'rows array required' });
|
||||
}
|
||||
if (!mapping || typeof mapping !== 'object') {
|
||||
return res.status(400).json({ error: 'mapping object required' });
|
||||
}
|
||||
if (rows.length > 2000) {
|
||||
return res.status(400).json({ error: 'Too many rows (max 2000 per preview)' });
|
||||
}
|
||||
|
||||
const computedPayloadHash = payloadHash || hashImportPayload(rows, mapping);
|
||||
const priorImport = await findPriorImport({
|
||||
contentHash: contentHash || null,
|
||||
payloadHash: computedPayloadHash,
|
||||
});
|
||||
|
||||
const preview = await buildPreview(rows, mapping, { sourceLabel });
|
||||
res.json({
|
||||
...preview,
|
||||
contentHash: contentHash || null,
|
||||
payloadHash: computedPayloadHash,
|
||||
alreadyImported: Boolean(priorImport),
|
||||
priorImport,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Influence import preview error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to build preview' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/import/commit', requireCurator, async (req, res) => {
|
||||
try {
|
||||
const { proposals, fileName, contentHash, payloadHash, force } = req.body || {};
|
||||
if (!Array.isArray(proposals)) {
|
||||
return res.status(400).json({ error: 'proposals array required' });
|
||||
}
|
||||
|
||||
if (!force && (contentHash || payloadHash)) {
|
||||
const priorImport = await findPriorImport({
|
||||
contentHash: contentHash || null,
|
||||
payloadHash: payloadHash || null,
|
||||
});
|
||||
if (priorImport) {
|
||||
return res.status(409).json({
|
||||
error: 'This file or identical data was already imported',
|
||||
code: 'ALREADY_IMPORTED',
|
||||
priorImport,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await commitProposals(proposals, {
|
||||
userId: req.curatorUser.id,
|
||||
req,
|
||||
fileName,
|
||||
});
|
||||
|
||||
await logCuratorAction({
|
||||
userId: req.curatorUser.id,
|
||||
action: 'influence.import',
|
||||
resourceType: 'influence_source',
|
||||
resourceId: 0,
|
||||
details: {
|
||||
fileName: fileName || null,
|
||||
inserted: result.inserted,
|
||||
skipped: result.skipped,
|
||||
attempted: result.attempted,
|
||||
contentHash: contentHash || null,
|
||||
payloadHash: payloadHash || null,
|
||||
forced: Boolean(force),
|
||||
},
|
||||
req,
|
||||
});
|
||||
|
||||
res.json({
|
||||
...result,
|
||||
contentHash: contentHash || null,
|
||||
payloadHash: payloadHash || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Influence import commit error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to commit import' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user