Files
Art-gallery/server/influence-import-service.js
Danila KhodjaefandCursor 48bd17e985 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>
2026-07-16 18:54:26 +03:00

856 lines
26 KiB
JavaScript

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