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
+272
View File
@@ -188,6 +188,23 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
});
}
async function fileToBase64Raw(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('Could not read file'));
return;
}
const comma = result.indexOf(',');
resolve(comma >= 0 ? result.slice(comma + 1) : result);
};
reader.onerror = () => reject(new Error('Could not read file'));
reader.readAsDataURL(file);
});
}
function mimeTypeFromFilename(filename: string): string | null {
const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
switch (ext) {
@@ -516,6 +533,159 @@ export const api = {
return res.json();
}),
listInfluences: (params: {
artistId?: number;
paintingId?: number;
q?: string;
limit?: number;
offset?: number;
} = {}) => {
const qs = new URLSearchParams();
if (params.artistId) qs.set('artistId', String(params.artistId));
if (params.paintingId) qs.set('paintingId', String(params.paintingId));
if (params.q) qs.set('q', params.q);
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
const q = qs.toString();
return fetchJson<{ items: InfluenceEdgeItem[]; total: number; limit: number; offset: number }>(
`${API}/influences${q ? `?${q}` : ''}`,
);
},
getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => {
const qs = new URLSearchParams();
if (params.artistId) qs.set('artistId', String(params.artistId));
if (params.paintingId) qs.set('paintingId', String(params.paintingId));
return fetchJson<InfluenceGraph>(`${API}/influences/graph?${qs.toString()}`);
},
createInfluence: (payload: {
paintingId: number;
sourceType: 'painting' | 'artist' | 'movement';
sourcePaintingId?: number;
sourceArtistId?: number;
sourceMovementId?: number;
notes?: string;
source?: string;
sourceUrl?: string;
}) =>
fetch(`${API}/influences`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Create failed: ${res.status}`);
}
return res.json() as Promise<{ id: number }>;
}),
updateInfluence: (
id: number,
payload: Partial<{
notes: string;
source: string;
sourceUrl: string;
confidence: string;
sourceType: 'painting' | 'artist' | 'movement';
sourcePaintingId: number;
sourceArtistId: number;
sourceMovementId: number;
}>,
) =>
fetch(`${API}/influences/${id}`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Update failed: ${res.status}`);
}
return res.json() as Promise<{ id: number }>;
}),
deleteInfluence: (id: number) =>
fetch(`${API}/influences/${id}`, {
...fetchCredentials,
method: 'DELETE',
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Delete failed: ${res.status}`);
}
return res.json() as Promise<{ ok: boolean }>;
}),
parseInfluenceImport: async (file: File, sheet?: string) => {
const contentBase64 = await fileToBase64Raw(file);
return fetch(`${API}/influences/import/parse`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
sheet,
contentBase64,
}),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Parse failed: ${res.status}`);
}
return res.json() as Promise<InfluenceImportParseResult>;
});
},
previewInfluenceImport: (payload: {
rows: Record<string, string>[];
mapping: Record<string, string>;
sourceLabel?: string;
contentHash?: string;
payloadHash?: string;
}) =>
fetch(`${API}/influences/import/preview`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Preview failed: ${res.status}`);
}
return res.json() as Promise<InfluenceImportPreview>;
}),
commitInfluenceImport: (payload: {
proposals: InfluenceImportProposal[];
fileName?: string;
contentHash?: string;
payloadHash?: string;
force?: boolean;
}) =>
fetch(`${API}/influences/import/commit`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err = new Error(body.error || `Commit failed: ${res.status}`) as Error & {
code?: string;
priorImport?: InfluencePriorImport;
};
err.code = body.code;
err.priorImport = body.priorImport;
throw err;
}
return res.json() as Promise<{ inserted: number; skipped: number; attempted: number }>;
}),
preloadArtistImages,
};
@@ -542,6 +712,108 @@ export interface TranslationDetail {
}>;
}
export interface InfluenceEdgeItem {
id: number;
paintingId: number;
paintingTitle: string;
paintingYear: number | null;
artistId: number;
artistName: string;
sourceType: 'painting' | 'artist' | 'movement';
sourcePaintingId: number | null;
sourceArtistId: number | null;
sourceMovementId: number | null;
sourceLabel: string | null;
notes: string | null;
source: string | null;
sourceUrl: string | null;
aspects: string | null;
quote: string | null;
confidence: string | null;
discoveredVia: string | null;
updatedAt: string | null;
}
export interface InfluenceGraph {
focus: { artistId: number; paintingId: number | null; label: string };
nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>;
edges: Array<{ id: number; from: string; to: string; direction: string; label: string }>;
}
export interface InfluencePriorImport {
importedAt: string;
username: string | null;
fileName: string | null;
inserted: number | null;
contentHash: string | null;
payloadHash: string | null;
match: 'file' | 'data' | 'unknown';
}
export interface InfluenceImportParseResult {
filename: string;
format: string;
sheets: string[] | null;
sheet: string | null;
columns: string[];
rowCount: number;
sampleRows: Record<string, string>[];
rows?: Record<string, string>[];
suggestedPreset: string;
suggestedMapping: Record<string, string>;
roles: string[];
presets: Array<{ id: string; label: string; mapping: Record<string, string> }>;
contentHash?: string;
payloadHash?: string;
alreadyImported?: boolean;
priorImport?: InfluencePriorImport | null;
}
export interface InfluenceImportProposal {
rowIndex: number;
direction: string;
paintingId: number;
paintingTitle: string;
artistId: number;
artistName: string;
sourceType: string;
sourcePaintingId: number | null;
sourceArtistId: number | null;
sourceMovementId: number | null;
sourceLabel: string;
token: string;
notes: string | null;
source: string | null;
sourceUrl: string | null;
confidence: string;
discoveredVia: string;
edgeKey: string;
action: 'create' | 'skip';
reason: string | null;
}
export interface InfluenceImportPreview {
proposals: InfluenceImportProposal[];
warnings: Array<{
rowIndex: number;
message: string;
token?: string;
direction?: string;
candidates?: Array<{ sourceType: string; sourcePaintingId?: number; label: string }>;
}>;
counts: {
rows: number;
proposals: number;
willCreate: number;
willSkip: number;
errors: number;
};
contentHash?: string | null;
payloadHash?: string | null;
alreadyImported?: boolean;
priorImport?: InfluencePriorImport | null;
}
export function debugImageProxyUrl(
imageUrl: string,
context?: { searchUrl?: string; source?: string }