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:
Danila Khodjaef
2026-07-16 18:54:26 +03:00
co-authored by Cursor
parent 62d7ebbe6a
commit 48bd17e985
21 changed files with 3402 additions and 15 deletions
+2
View File
@@ -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, {
+855
View File
@@ -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,
};
+643
View File
@@ -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;