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
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user