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
+17
View File
@@ -195,6 +195,23 @@ Requires curator session. Base path: `/api/translations`.
| `PUT` | `/api/translations/:entityType/:id` | Upsert fields `{ locale, fields, status }` |
| `POST` | `/api/translations/:entityType/:id/publish` | Publish all draft/reviewed rows for locale |
## Influences (curator)
Requires curator session. Base path: `/api/influences`. See [influence-import.md](influence-import.md).
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/influences` | List edges (`artistId`, `paintingId`, `q`, pagination) |
| `GET` | `/api/influences/graph?artistId=` | Neighborhood nodes/edges for visualization |
| `POST` | `/api/influences` | Create edge `{ paintingId, sourceType, sourceArtistId\|sourcePaintingId\|sourceMovementId, … }` |
| `PATCH` | `/api/influences/:id` | Update notes / remap source |
| `DELETE` | `/api/influences/:id` | Delete edge |
| `POST` | `/api/influences/import/parse` | Parse upload `{ filename, contentBase64, sheet? }` — returns `contentHash` / `payloadHash` / `alreadyImported` |
| `POST` | `/api/influences/import/preview` | Validate `{ rows, mapping, contentHash?, payloadHash? }` |
| `POST` | `/api/influences/import/commit` | Insert `{ proposals, contentHash?, payloadHash?, force? }``409` if duplicate unless `force` |
Public painting detail still exposes read-only `influencedBy` / `influenced` (unchanged).
---
## `GET /api/search`
+1 -1
View File
@@ -213,7 +213,7 @@ Append-only log of curator debug mutations (fix/clear/upload/delete, checkup fla
| `ip_address` | VARCHAR(45) | Client IP (respects `TRUST_PROXY`) |
| `created_at` | TIMESTAMPTZ | |
**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`.
**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`, `translation.upsert`, `translation.publish`, `influence.create`, `influence.update`, `influence.delete`, `influence.import`.
Example query in pgAdmin:
+2 -2
View File
@@ -94,14 +94,14 @@ CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password
```
Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup. Mutations are logged in `curator_audit_log` (view in pgAdmin).
Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup / Translations / **Influences**. Mutations are logged in `curator_audit_log` (view in pgAdmin).
**Roles:**
| Role | Access |
|------|--------|
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
| Curator | Above + debug mode, Checkup, image fix/upload/delete APIs |
| Curator | Above + debug mode, Checkup, Translations, Influences (import/CRUD/graph), image fix/upload/delete APIs |
**Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):**
+2 -2
View File
@@ -1,10 +1,10 @@
this file contains draft for future releases and features
1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md)
2. tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities
2. ~~tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities~~ — done: curator Influences page (list CRUD + import wizard CSV/JSON/XLSX + neighborhood graph) — [influence-import.md](influence-import.md)
3. tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ?
4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md)
5. curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome
5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); see [DB_structure.md](DB_structure.md#curator_audit_log). (UI to browse logs is still item 3.)
6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search`
7. create guided tours (with text/extra infor, set of entities)
+9 -1
View File
@@ -50,6 +50,10 @@ Gallery/
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
│ │ ├── pages/CheckupPage.tsx # Image audit table
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
│ │ ├── i18n/ # react-i18next bootstrap
│ │ └── locales/{en,ru}/ # UI chrome strings
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
@@ -412,7 +416,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
| Role | Who | Can do |
|------|-----|--------|
| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images |
| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, debug API mutations |
| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, **Translations**, **Influences**, debug API mutations |
Curators sign in via **Curator login** in the site header. Sessions use an HTTP-only cookie (`gallery.sid`). The UI hides debug controls from guests; the server enforces the same rules on debug/checkup API routes (`401` without a valid session).
@@ -429,6 +433,8 @@ Curator-only workflow for reviewing and fixing local image files — not part of
| **Debug mode** | Home header toggle (curators only) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
| **Show more** | Home header checkbox (curators, when debug on) | Auto-opens the **More** modal on each painting / bio page load |
| **Checkup page** | Home header → **Checkup** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
| **Translations** | Home header → **Translations** (curators only) | Review/publish Russian `entity_translations` |
| **Influences** | Home header → **Influences** (curators only) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) |
| **Logout** | Home header (curators) | Ends session; hides debug tools |
| **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) |
@@ -474,4 +480,6 @@ See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md#
| [harmonize-dev-prod.md](harmonize-dev-prod.md) | Incremental dev ↔ prod merge (catalog DB + images) |
| [DB_structure.md](DB_structure.md) | Tables and relationships |
| [API.md](API.md) | REST endpoints |
| [influence-import.md](influence-import.md) | Curator Influences tool — import wizard, CRUD, graph |
| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations |
| [data-and-images.md](data-and-images.md) | Image pipeline and seeding |
+79
View File
@@ -0,0 +1,79 @@
# Influence links import & curator tool
Curator tool to **list / add / delete** influence edges, **visualize** an artist neighborhood, and **import** CSV / JSON / XLSX files through a mapping wizard.
Entry: header **Influences** (curator session). Data lives in `painting_influence_sources` (legacy `painting_influences` mirrored for painting→painting).
---
## Expansion rule (artist-level files)
Input workbooks such as [`Inputs/artist_influences_web_sources.xlsx`](../Inputs/artist_influences_web_sources.xlsx) are **artist-centric**. On import:
- **Influenced by** tokens → attach as sources on **all paintings** of the subject artist
- **Influenced** tokens that resolve to an artist → reverse link: subject artist becomes a source on **all paintings** of the influenced artist (same as PainterPalette `Influencedon`)
Unresolved names (artists / movements / paintings not in the DB) are **skipped** with warnings — no auto-create stubs.
---
## Wizard column roles
| Role | Meaning |
|------|---------|
| `subject_artist` | Artist the row is about (required) |
| `subject_painting` | Optional work hint (contextual; expansion still uses all works) |
| `influenced_by` | Who/what influenced the subject (`;` / `,` separated) |
| `influenced` | Who the subject influenced |
| `notes` / `reference` / `source_url` | Citation metadata (URLs scraped from reference text) |
| `ignore` | Skip column |
### Presets
| Preset | Typical headers |
|--------|-----------------|
| Web sources | `Artist`, `Painting`, `Influenced by`, `Influenced`, `Reference (source + link)` |
| Story of Art | Same Title Case (+ chapter reference column) |
| Art influences | `artist`, `painting`, `influenced_by`, `influenced`, `reference` |
| Custom | Map any columns manually |
Token classification order: **artist → movement → painting title** (under subject artist, then global).
Committed edges use `confidence=curated`, `discovered_via=import-wizard`.
### Duplicate file / data guard
Each successful commit stores SHA-256 fingerprints in `curator_audit_log` (`influence.import` details):
- `contentHash` — raw file bytes
- `payloadHash` — normalized mapped rows (same data under another filename still matches)
On parse/preview, if either hash matches a prior import, the UI warns and **blocks commit** unless the curator checks **Import anyway (force)**. Individual edges remain unique via DB `ON CONFLICT` either way.
---
## API (curator)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/influences` | List (`artistId`, `paintingId`, `q`, `limit`, `offset`) |
| `GET` | `/api/influences/graph?artistId=` | Nodes + edges for SVG neighborhood |
| `POST` | `/api/influences` | Create one edge |
| `PATCH` | `/api/influences/:id` | Update metadata / remap source |
| `DELETE` | `/api/influences/:id` | Delete (+ legacy mirror) |
| `POST` | `/api/influences/import/parse` | `{ filename, contentBase64, sheet? }` → columns, hashes, `alreadyImported` |
| `POST` | `/api/influences/import/preview` | `{ rows, mapping, contentHash?, payloadHash? }` → proposals + duplicate check |
| `POST` | `/api/influences/import/commit` | `{ proposals, fileName?, contentHash?, payloadHash?, force? }``409 ALREADY_IMPORTED` unless `force` |
Audit: `influence.create` / `update` / `delete` / `import` in `curator_audit_log`.
---
## CLI still available
- `npm run dev:update-influences` — curated [`scripts/art-influences-data.js`](../scripts/art-influences-data.js)
- `npm run dev:import-painter-palette` — PainterPalette CSV
The wizard is the interactive path for ad-hoc spreadsheets under `Inputs/`.
See also [API.md](API.md), [DB_structure.md](DB_structure.md), [Plans.md](Plans.md).
+5 -2
View File
@@ -72,7 +72,7 @@ If migration fails with permission errors, grant schema rights to the app user f
`npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically.
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline and 3D halls without logging in.
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline and 3D halls without logging in.
See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit).
@@ -120,7 +120,7 @@ Image fetch can take hours if you run it for the entire catalog. The first line
| `npm run dev:server` | API with nodemon reload (local HMR workflow) |
| `npm run dev:client` | Vite dev server on :5173 |
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For Russian UI + catalog text, see [i18n-russian.md](i18n-russian.md). Quick reference: [FAC.md](FAC.md).
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For Russian UI + catalog text, see [i18n-russian.md](i18n-russian.md). Influence link import/CRUD: [influence-import.md](influence-import.md). Quick reference: [FAC.md](FAC.md).
**Production frontend:** build the client, then start the server:
@@ -274,6 +274,9 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time
| Catalog search returns empty / 500 | Search indexes missing | Run `npm run dev:migrate` (includes `migrate-search.sql`) or `npm run dev:migrate:search`; restart API |
| `fetch-artist-bios-ru` fails: `index row size … exceeds btree maximum` | Old `entity_translations` index on all `value` text | `npm run dev:migrate` (partial search index on name/title only), then re-run `npm run dev:fetch-artist-bios-ru` |
| Russian UI shows English catalog names | Translations not published | Curator → Translations → Publish; public API serves only `status = published` |
| Influence import leaves many unresolved tokens | Names not in catalog DB | Curator → Influences → Import warnings; fix spelling or add artists first; free-text traditions stay unresolved |
| Influence import created too many edges | Artist-level rows expand to all paintings | Expected (PainterPalette-style); delete unwanted edges in Influences list |
| Influence import blocked: already imported | Same file bytes or mapped data imported before | Expected; use **Import anyway** only if intentional, or skip |
| Frame still black after **Checked** | Gallery session not synced | Re-enter hall or toggle debug **Checked** from detail with gallery open behind overlay |
| Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run dev:find-duplicates`; merge or delete spare rows manually |
| **Failed to load movement gallery** / `Cannot GET /api/movements/:id/gallery` | Stale server process missing route | Restart `npm run dev:web` or `npm run dev:server` after pulling API changes |
+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 }
+5 -1
View File
@@ -11,6 +11,7 @@ import enBio from '../locales/en/bio.json';
import enAnnotations from '../locales/en/annotations.json';
import enDebug from '../locales/en/debug.json';
import enTranslations from '../locales/en/translations.json';
import enInfluences from '../locales/en/influences.json';
import ruCommon from '../locales/ru/common.json';
import ruHome from '../locales/ru/home.json';
@@ -21,6 +22,7 @@ import ruBio from '../locales/ru/bio.json';
import ruAnnotations from '../locales/ru/annotations.json';
import ruDebug from '../locales/ru/debug.json';
import ruTranslations from '../locales/ru/translations.json';
import ruInfluences from '../locales/ru/influences.json';
const initialLocale = readStoredLocale();
writeStoredLocale(initialLocale);
@@ -29,7 +31,7 @@ void i18n.use(initReactI18next).init({
lng: initialLocale,
fallbackLng: 'en',
supportedLngs: ['en', 'ru'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences'],
defaultNS: 'common',
resources: {
en: {
@@ -42,6 +44,7 @@ void i18n.use(initReactI18next).init({
annotations: enAnnotations,
debug: enDebug,
translations: enTranslations,
influences: enInfluences,
},
ru: {
common: ruCommon,
@@ -53,6 +56,7 @@ void i18n.use(initReactI18next).init({
annotations: ruAnnotations,
debug: ruDebug,
translations: ruTranslations,
influences: ruInfluences,
},
},
interpolation: { escapeValue: false },
+1
View File
@@ -14,6 +14,7 @@
"showMoreDebug": "Show more (debug)",
"checkup": "Checkup",
"translations": "Translations",
"influences": "Influences",
"curatorRequiredTitle": "Curator access required",
"curatorRequiredBody": "Sign in as a curator to use this tool.",
"backToGalleryBtn": "Back to gallery"
+70
View File
@@ -0,0 +1,70 @@
{
"title": "Influence links",
"back": "← Back to gallery",
"tabList": "List",
"tabImport": "Import",
"tabGraph": "Graph",
"loadFailed": "Failed to load influences",
"searchPlaceholder": "Search artist, painting, source…",
"refresh": "Refresh",
"loading": "Loading…",
"loadingParse": "Reading file…",
"loadingPreview": "Validating influence links…",
"loadingCommit": "Importing links into the database…",
"loadingSave": "Saving link…",
"addEdge": "Add link",
"cancelAdd": "Cancel",
"total": "{{count}} links",
"filtered": "filtered by artist",
"clearFilter": "Clear artist filter",
"subjectPainting": "Subject painting",
"searchPainting": "Search painting…",
"sourceType": "Source type",
"typeArtist": "Artist",
"typePainting": "Painting",
"typeMovement": "Movement",
"sourceEntity": "Source entity",
"searchSource": "Search source…",
"notes": "Notes",
"saveEdge": "Save link",
"addRequiresIds": "Pick a subject painting and a source entity",
"colSubject": "Subject",
"colSource": "Source",
"colType": "Type",
"colNotes": "Notes",
"colActions": "Actions",
"colAction": "Action",
"colDirection": "Direction",
"delete": "Delete",
"confirmDelete": "Delete this influence link?",
"noEdges": "No influence links match.",
"stepUpload": "1. Upload",
"stepMapping": "2. Map columns",
"stepPreview": "3. Validate",
"stepDone": "4. Done",
"uploadHelp": "Choose a CSV, JSON, or XLSX file with influence rows (e.g. Inputs/artist_influences_web_sources.xlsx).",
"parseFailed": "Failed to parse file",
"previewFailed": "Failed to build preview",
"commitFailed": "Failed to commit import",
"rowsMissing": "File rows were not returned (too large). Use a smaller file (≤2000 rows).",
"fileInfo": "{{name}} · {{rows}} rows · {{format}}",
"sheet": "Sheet",
"preset": "Mapping preset",
"column": "Column",
"role": "Role",
"sample": "Sample",
"backStep": "Back",
"runPreview": "Validate & preview",
"previewCounts": "Will create {{create}} · skip {{skip}} · row errors {{errors}} · proposals {{proposals}}",
"warnings": "{{count}} warnings",
"commitImport": "Import {{count}} links",
"commitSummary": "Imported {{inserted}} links ({{skipped}} already present or skipped).",
"alreadyImportedWarn": "Already imported {{when}} by {{who}} ({{match}}: {{file}}). Commit is blocked unless you force re-import.",
"alreadyImportedBlock": "This file or identical data was already imported. Enable “Import anyway” to force, or cancel.",
"forceImport": "Import anyway (force — may recreate skipped edges only; existing edges stay unique)",
"matchFile": "same file bytes",
"matchData": "same mapped data",
"searchArtist": "Search artist for neighborhood graph…",
"graphEmpty": "Select an artist to visualize influence neighbors.",
"graphStats": "{{nodes}} nodes · {{edges}} edges"
}
+1
View File
@@ -14,6 +14,7 @@
"showMoreDebug": "Показать больше (отладка)",
"checkup": "Проверка",
"translations": "Переводы",
"influences": "Влияния",
"curatorRequiredTitle": "Требуется доступ куратора",
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
"backToGalleryBtn": "Вернуться в галерею"
+70
View File
@@ -0,0 +1,70 @@
{
"title": "Связи влияния",
"back": "← Назад в галерею",
"tabList": "Список",
"tabImport": "Импорт",
"tabGraph": "Граф",
"loadFailed": "Не удалось загрузить связи",
"searchPlaceholder": "Поиск: художник, картина, источник…",
"refresh": "Обновить",
"loading": "Загрузка…",
"loadingParse": "Чтение файла…",
"loadingPreview": "Проверка связей влияния…",
"loadingCommit": "Импорт связей в базу…",
"loadingSave": "Сохранение связи…",
"addEdge": "Добавить связь",
"cancelAdd": "Отмена",
"total": "{{count}} связей",
"filtered": "фильтр по художнику",
"clearFilter": "Сбросить фильтр",
"subjectPainting": "Картина (субъект)",
"searchPainting": "Поиск картины…",
"sourceType": "Тип источника",
"typeArtist": "Художник",
"typePainting": "Картина",
"typeMovement": "Направление",
"sourceEntity": "Источник",
"searchSource": "Поиск источника…",
"notes": "Заметки",
"saveEdge": "Сохранить связь",
"addRequiresIds": "Выберите картину и источник",
"colSubject": "Субъект",
"colSource": "Источник",
"colType": "Тип",
"colNotes": "Заметки",
"colActions": "Действия",
"colAction": "Действие",
"colDirection": "Направление",
"delete": "Удалить",
"confirmDelete": "Удалить эту связь влияния?",
"noEdges": "Связи не найдены.",
"stepUpload": "1. Файл",
"stepMapping": "2. Столбцы",
"stepPreview": "3. Проверка",
"stepDone": "4. Готово",
"uploadHelp": "Выберите CSV, JSON или XLSX со связями влияния (например Inputs/artist_influences_web_sources.xlsx).",
"parseFailed": "Не удалось разобрать файл",
"previewFailed": "Не удалось построить превью",
"commitFailed": "Не удалось выполнить импорт",
"rowsMissing": "Строки файла не получены (слишком большой файл). Используйте ≤2000 строк.",
"fileInfo": "{{name}} · {{rows}} строк · {{format}}",
"sheet": "Лист",
"preset": "Шаблон сопоставления",
"column": "Столбец",
"role": "Роль",
"sample": "Пример",
"backStep": "Назад",
"runPreview": "Проверить",
"previewCounts": "Создать {{create}} · пропустить {{skip}} · ошибки строк {{errors}} · предложений {{proposals}}",
"warnings": "{{count}} предупреждений",
"commitImport": "Импортировать {{count}} связей",
"commitSummary": "Импортировано {{inserted}} (пропущено {{skipped}}).",
"alreadyImportedWarn": "Уже импортировано {{when}} пользователем {{who}} ({{match}}: {{file}}). Импорт заблокирован, пока не включите принудительный повтор.",
"alreadyImportedBlock": "Этот файл или те же данные уже импортировались. Включите «Импортировать всё равно» или отмените.",
"forceImport": "Импортировать всё равно (принудительно — существующие связи остаются уникальными)",
"matchFile": "те же байты файла",
"matchData": "те же сопоставленные данные",
"searchArtist": "Поиск художника для графа…",
"graphEmpty": "Выберите художника, чтобы увидеть соседей по влиянию.",
"graphStats": "{{nodes}} узлов · {{edges}} рёбер"
}
+43 -3
View File
@@ -8,6 +8,7 @@ import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage';
import TranslationsPage from '../pages/TranslationsPage';
import InfluencesPage from '../pages/InfluencesPage';
import CuratorLoginModal from '../components/CuratorLoginModal';
import CatalogSearchBar from '../components/CatalogSearchBar';
import LocaleSwitcher from '../components/LocaleSwitcher';
@@ -16,6 +17,7 @@ import '../components/CatalogSearchBar.css';
import '../components/CuratorLoginModal.css';
import '../components/LocaleSwitcher.css';
import '../pages/TranslationsPage.css';
import '../pages/InfluencesPage.css';
import { useAuth } from '../context/AuthContext';
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
@@ -28,6 +30,7 @@ type View =
| { type: 'timeline' }
| { type: 'checkup' }
| { type: 'translations' }
| { type: 'influences' }
| { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
@@ -128,7 +131,7 @@ export default function HomePage() {
const [debugMode, setDebugMode] = useState(readDebugMode);
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
const [loginOpen, setLoginOpen] = useState(false);
const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | null>(null);
const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | 'influences' | null>(null);
const effectiveDebugMode = debugMode && isCurator;
const [galleryRevision, setGalleryRevision] = useState(0);
const viewRef = useRef(view);
@@ -222,7 +225,7 @@ export default function HomePage() {
writeDebugShowMore(enabled);
};
const openCuratorLogin = (redirect: 'checkup' | 'translations' | null = null) => {
const openCuratorLogin = (redirect: 'checkup' | 'translations' | 'influences' | null = null) => {
setLoginRedirect(redirect);
setLoginOpen(true);
};
@@ -234,6 +237,8 @@ export default function HomePage() {
setView({ type: 'checkup' });
} else if (loginRedirect === 'translations') {
setView({ type: 'translations' });
} else if (loginRedirect === 'influences') {
setView({ type: 'influences' });
}
setLoginRedirect(null);
};
@@ -242,7 +247,7 @@ export default function HomePage() {
await logout();
writeDebugMode(false);
setDebugMode(false);
if (view.type === 'checkup' || view.type === 'translations') {
if (view.type === 'checkup' || view.type === 'translations' || view.type === 'influences') {
goToTimelineHome();
}
};
@@ -263,6 +268,14 @@ export default function HomePage() {
setView({ type: 'translations' });
};
const openInfluences = () => {
if (!isCurator) {
openCuratorLogin('influences');
return;
}
setView({ type: 'influences' });
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
@@ -746,6 +759,25 @@ export default function HomePage() {
</div>
)}
{view.type === 'influences' && (
isCurator ? (
<InfluencesPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('influences')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'translations' && (
isCurator ? (
<TranslationsPage onBack={goToTimelineHome} />
@@ -824,6 +856,14 @@ export default function HomePage() {
/>
Show more
</label>
<button
type="button"
className="checkup-link-btn"
onClick={openInfluences}
title="Manage influence links"
>
{t('influences')}
</button>
<button
type="button"
className="checkup-link-btn"
+378
View File
@@ -0,0 +1,378 @@
.influences-page {
max-width: 1200px;
margin: 0 auto;
padding: 1.25rem 1.5rem 3rem;
color: #e8d5b5;
min-height: 100vh;
position: relative;
}
.influences-loading-overlay.gallery-loading-marker-overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(10, 10, 20, 0.72);
pointer-events: auto;
}
.influences-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.25rem;
margin-bottom: 1rem;
}
.influences-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 650;
flex: 1;
color: #e8d5b5;
}
.influences-back {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.75rem;
cursor: pointer;
}
.influences-back:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.influences-tabs {
display: flex;
gap: 0.35rem;
}
.influences-tabs button {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.influences-tabs button:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.influences-tabs button.active {
background: rgba(232, 160, 64, 0.2);
color: #e8d5b5;
border-color: #e8a040;
}
.influences-error {
background: rgba(139, 0, 0, 0.3);
border: 1px solid rgba(255, 170, 170, 0.4);
color: #ffaaaa;
padding: 0.65rem 0.85rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.influences-success {
background: rgba(22, 101, 52, 0.35);
border: 1px solid rgba(134, 239, 172, 0.35);
color: #bbf7d0;
padding: 0.65rem 0.85rem;
border-radius: 6px;
}
.influences-dup-warn {
background: rgba(120, 53, 15, 0.45);
border: 1px solid rgba(251, 191, 36, 0.45);
color: #fde68a;
padding: 0.75rem 0.9rem;
border-radius: 8px;
display: grid;
gap: 0.65rem;
}
.influences-dup-warn p {
margin: 0;
color: #fde68a;
}
.influences-force {
display: flex;
align-items: center;
gap: 0.5rem;
color: #e8d5b5;
font-size: 0.9rem;
}
.influences-force input {
accent-color: #e8a040;
min-width: auto;
}
.influences-panel {
display: flex;
flex-direction: column;
gap: 1rem;
color: #e8d5b5;
}
.influences-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.influences-toolbar input[type='search'],
.influences-add input,
.influences-add textarea,
.influences-add select,
.wizard-block select,
.wizard-block input {
border: 1px solid rgba(201, 169, 110, 0.35);
border-radius: 6px;
padding: 0.45rem 0.65rem;
min-width: 12rem;
font: inherit;
background: rgba(15, 15, 26, 0.85);
color: #e8d5b5;
}
.influences-toolbar input::placeholder,
.influences-add input::placeholder,
.wizard-block input::placeholder {
color: rgba(201, 169, 110, 0.5);
}
.influences-toolbar button,
.wizard-actions button,
.influences-add button,
.influences-hits button {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.75rem;
cursor: pointer;
font: inherit;
}
.influences-toolbar button:hover,
.wizard-actions button:hover,
.influences-add button:hover,
.influences-hits button:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.influences-meta {
color: rgba(201, 169, 110, 0.75);
font-size: 0.9rem;
}
.influences-add {
display: grid;
gap: 0.65rem;
padding: 1rem;
border: 1px solid rgba(201, 169, 110, 0.25);
border-radius: 8px;
background: rgba(15, 15, 26, 0.55);
color: #e8d5b5;
}
.influences-add label {
display: grid;
gap: 0.35rem;
font-size: 0.9rem;
color: #e8d5b5;
}
.influences-hits {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.influences-table-wrap {
overflow: auto;
border: 1px solid rgba(201, 169, 110, 0.25);
border-radius: 8px;
}
.influences-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
color: #e8d5b5;
}
.influences-table th,
.influences-table td {
border-bottom: 1px solid rgba(201, 169, 110, 0.15);
padding: 0.55rem 0.65rem;
text-align: left;
vertical-align: top;
color: #e8d5b5;
}
.influences-table th {
background: rgba(15, 15, 26, 0.85);
font-weight: 600;
color: #c9a96e;
}
.notes-cell {
max-width: 18rem;
white-space: pre-wrap;
word-break: break-word;
}
.muted {
color: rgba(201, 169, 110, 0.65);
font-size: 0.85rem;
}
.linkish {
background: none;
border: none;
color: #e8a040;
cursor: pointer;
padding: 0;
font: inherit;
text-align: left;
}
.linkish:hover {
color: #e8d5b5;
}
.danger {
color: #ffaaaa !important;
border-color: rgba(255, 170, 170, 0.4) !important;
}
.wizard-steps {
display: flex;
gap: 0.75rem;
list-style: none;
margin: 0;
padding: 0;
flex-wrap: wrap;
}
.wizard-steps li {
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: rgba(15, 15, 26, 0.85);
border: 1px solid rgba(201, 169, 110, 0.25);
color: rgba(201, 169, 110, 0.75);
font-size: 0.85rem;
}
.wizard-steps li.active {
background: rgba(232, 160, 64, 0.2);
border-color: #e8a040;
color: #e8d5b5;
}
.wizard-block {
display: grid;
gap: 0.85rem;
color: #e8d5b5;
}
.wizard-block p,
.wizard-block label,
.wizard-block summary {
color: #e8d5b5;
}
.wizard-actions {
display: flex;
gap: 0.5rem;
}
.warnings-list {
max-height: 12rem;
overflow: auto;
font-size: 0.85rem;
color: #fbbf24;
}
.mapping-table select {
min-width: 10rem;
}
.influences-graph h2 {
margin: 0 0 0.25rem;
font-size: 1.15rem;
color: #e8d5b5;
}
.influences-svg {
width: 100%;
max-width: 760px;
height: auto;
background: rgba(15, 15, 26, 0.65);
border: 1px solid rgba(201, 169, 110, 0.25);
border-radius: 10px;
}
.influences-svg .edge-in {
stroke: rgba(201, 169, 110, 0.55);
stroke-width: 1.5;
opacity: 0.85;
}
.influences-svg .edge-out {
stroke: #e8a040;
stroke-width: 1.5;
opacity: 0.85;
}
.influences-svg .node circle {
fill: rgba(201, 169, 110, 0.35);
stroke: #c9a96e;
stroke-width: 1.5;
cursor: pointer;
}
.influences-svg .node-artist circle {
fill: rgba(96, 165, 250, 0.35);
stroke: #93c5fd;
}
.influences-svg .node-movement circle {
fill: rgba(74, 222, 128, 0.3);
stroke: #86efac;
}
.influences-svg .node-painting circle {
fill: rgba(251, 146, 60, 0.3);
stroke: #fdba74;
}
.influences-svg .node.focus circle {
fill: #e8a040;
stroke: #e8d5b5;
}
.influences-svg .node text {
font-size: 10px;
fill: #e8d5b5;
pointer-events: none;
}
.influences-svg .node.focus text {
font-weight: 650;
fill: #e8d5b5;
}
+831
View File
@@ -0,0 +1,831 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
api,
type InfluenceEdgeItem,
type InfluenceGraph,
type InfluenceImportParseResult,
type InfluenceImportPreview,
type InfluenceImportProposal,
} from '../api/client';
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import './InfluencesPage.css';
type Tab = 'list' | 'import' | 'graph';
type WizardStep = 'upload' | 'mapping' | 'preview' | 'done';
interface Props {
onBack: () => void;
}
const ROLE_OPTIONS = [
'ignore',
'subject_artist',
'subject_painting',
'influenced_by',
'influenced',
'notes',
'reference',
'source_url',
] as const;
export default function InfluencesPage({ onBack }: Props) {
const { t } = useTranslation('influences');
const [tab, setTab] = useState<Tab>('list');
const [error, setError] = useState<string | null>(null);
// List
const [items, setItems] = useState<InfluenceEdgeItem[]>([]);
const [total, setTotal] = useState(0);
const [q, setQ] = useState('');
const [loading, setLoading] = useState(false);
const [filterArtistId, setFilterArtistId] = useState<number | null>(null);
// Add form
const [showAdd, setShowAdd] = useState(false);
const [addPaintingQuery, setAddPaintingQuery] = useState('');
const [addSourceQuery, setAddSourceQuery] = useState('');
const [addSourceType, setAddSourceType] = useState<'artist' | 'painting' | 'movement'>('artist');
const [addPaintingId, setAddPaintingId] = useState<number | null>(null);
const [addSourceId, setAddSourceId] = useState<number | null>(null);
const [addNotes, setAddNotes] = useState('');
const [searchHits, setSearchHits] = useState<Array<{ type: string; id: number; label: string }>>([]);
const [sourceHits, setSourceHits] = useState<Array<{ type: string; id: number; label: string }>>([]);
// Graph
const [graphArtistQuery, setGraphArtistQuery] = useState('');
const [graphArtistId, setGraphArtistId] = useState<number | null>(null);
const [graphArtistHits, setGraphArtistHits] = useState<Array<{ id: number; label: string }>>([]);
const [graph, setGraph] = useState<InfluenceGraph | null>(null);
// Import wizard
const [wizardStep, setWizardStep] = useState<WizardStep>('upload');
const [parseResult, setParseResult] = useState<InfluenceImportParseResult | null>(null);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [selectedSheet, setSelectedSheet] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [preview, setPreview] = useState<InfluenceImportPreview | null>(null);
const [busy, setBusy] = useState(false);
const [busyMessage, setBusyMessage] = useState('');
const [commitResult, setCommitResult] = useState<{ inserted: number; skipped: number } | null>(null);
const [forceImport, setForceImport] = useState(false);
const startBusy = (message: string) => {
setBusyMessage(message);
setBusy(true);
};
const stopBusy = () => {
setBusy(false);
setBusyMessage('');
};
const loadList = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.listInfluences({
q: q || undefined,
artistId: filterArtistId || undefined,
limit: 200,
});
setItems(data.items);
setTotal(data.total);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [q, filterArtistId, t]);
useEffect(() => {
if (tab === 'list') void loadList();
}, [tab, loadList]);
const runSearch = async (query: string, types: string) => {
if (query.trim().length < 2) return [] as Array<{ type: string; id: number; label: string }>;
const data = await api.search(query.trim(), { types, limit: 12 });
return data.results.map((r) => {
if (r.type === 'painting') {
return { type: r.type, id: r.id, label: `${r.artist_name}${r.title}` };
}
return { type: r.type, id: r.id, label: r.name };
});
};
useEffect(() => {
const handle = setTimeout(() => {
void (async () => {
try {
setSearchHits(await runSearch(addPaintingQuery, 'painting'));
} catch {
setSearchHits([]);
}
})();
}, 250);
return () => clearTimeout(handle);
}, [addPaintingQuery]);
useEffect(() => {
const handle = setTimeout(() => {
void (async () => {
try {
setSourceHits(await runSearch(addSourceQuery, addSourceType));
} catch {
setSourceHits([]);
}
})();
}, 250);
return () => clearTimeout(handle);
}, [addSourceQuery, addSourceType]);
useEffect(() => {
const handle = setTimeout(() => {
void (async () => {
try {
const hits = await runSearch(graphArtistQuery, 'artist');
setGraphArtistHits(hits.filter((h) => h.type === 'artist').map((h) => ({ id: h.id, label: h.label })));
} catch {
setGraphArtistHits([]);
}
})();
}, 250);
return () => clearTimeout(handle);
}, [graphArtistQuery]);
const loadGraph = async (artistId: number) => {
setError(null);
try {
const data = await api.getInfluenceGraph({ artistId });
setGraph(data);
setGraphArtistId(artistId);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
}
};
const handleDelete = async (id: number) => {
if (!window.confirm(t('confirmDelete'))) return;
try {
await api.deleteInfluence(id);
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
}
};
const handleCreate = async () => {
if (!addPaintingId || !addSourceId) {
setError(t('addRequiresIds'));
return;
}
startBusy(t('loadingSave'));
setError(null);
try {
const payload: Parameters<typeof api.createInfluence>[0] = {
paintingId: addPaintingId,
sourceType: addSourceType,
notes: addNotes || undefined,
source: 'curator-ui',
};
if (addSourceType === 'artist') payload.sourceArtistId = addSourceId;
if (addSourceType === 'painting') payload.sourcePaintingId = addSourceId;
if (addSourceType === 'movement') payload.sourceMovementId = addSourceId;
await api.createInfluence(payload);
setShowAdd(false);
setAddPaintingId(null);
setAddSourceId(null);
setAddNotes('');
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
stopBusy();
}
};
const onFileChosen = async (file: File | null) => {
if (!file) return;
setPendingFile(file);
startBusy(t('loadingParse'));
setError(null);
setCommitResult(null);
setForceImport(false);
try {
const parsed = await api.parseInfluenceImport(file);
setParseResult(parsed);
setMapping(parsed.suggestedMapping);
setSelectedSheet(parsed.sheet);
setWizardStep('mapping');
setPreview(null);
} catch (err) {
setError(err instanceof Error ? err.message : t('parseFailed'));
} finally {
stopBusy();
}
};
const reparseSheet = async (sheet: string) => {
if (!pendingFile) return;
startBusy(t('loadingParse'));
try {
const parsed = await api.parseInfluenceImport(pendingFile, sheet);
setParseResult(parsed);
setMapping(parsed.suggestedMapping);
setSelectedSheet(sheet);
} catch (err) {
setError(err instanceof Error ? err.message : t('parseFailed'));
} finally {
stopBusy();
}
};
const applyPreset = (presetId: string) => {
if (!parseResult) return;
const preset = parseResult.presets.find((p) => p.id === presetId);
if (!preset) return;
const next: Record<string, string> = {};
for (const col of parseResult.columns) next[col] = 'ignore';
for (const [col, role] of Object.entries(preset.mapping)) {
if (parseResult.columns.includes(col)) next[col] = role;
}
// Fill gaps with auto suggestions
for (const [col, role] of Object.entries(parseResult.suggestedMapping)) {
if (next[col] === 'ignore' && role !== 'ignore') next[col] = role;
}
setMapping(next);
};
const runPreview = async () => {
if (!parseResult?.rows) {
setError(t('rowsMissing'));
return;
}
startBusy(t('loadingPreview'));
setError(null);
try {
const result = await api.previewInfluenceImport({
rows: parseResult.rows,
mapping,
sourceLabel: parseResult.filename,
contentHash: parseResult.contentHash,
payloadHash: parseResult.payloadHash,
});
setPreview(result);
setWizardStep('preview');
if (result.alreadyImported) setForceImport(false);
} catch (err) {
setError(err instanceof Error ? err.message : t('previewFailed'));
} finally {
stopBusy();
}
};
const runCommit = async () => {
if (!preview) return;
const already = preview.alreadyImported || parseResult?.alreadyImported;
if (already && !forceImport) {
setError(t('alreadyImportedBlock'));
return;
}
startBusy(t('loadingCommit'));
setError(null);
try {
const result = await api.commitInfluenceImport({
proposals: preview.proposals,
fileName: parseResult?.filename,
contentHash: preview.contentHash || parseResult?.contentHash,
payloadHash: preview.payloadHash || parseResult?.payloadHash,
force: forceImport,
});
setCommitResult(result);
setWizardStep('done');
await loadList();
} catch (err) {
const e = err as Error & { code?: string };
if (e.code === 'ALREADY_IMPORTED') {
setError(t('alreadyImportedBlock'));
} else {
setError(err instanceof Error ? err.message : t('commitFailed'));
}
} finally {
stopBusy();
}
};
const priorImport = preview?.priorImport || parseResult?.priorImport || null;
const alreadyImported = Boolean(preview?.alreadyImported || parseResult?.alreadyImported);
const createProposals = useMemo(
() => (preview?.proposals || []).filter((p) => p.action === 'create').slice(0, 200),
[preview],
);
return (
<div className="influences-page">
{busy && (
<GalleryLoadingMarker
overlay
className="influences-loading-overlay"
message={busyMessage || t('loading')}
/>
)}
<header className="influences-header">
<button type="button" className="influences-back" onClick={onBack}>
{t('back')}
</button>
<h1>{t('title')}</h1>
<nav className="influences-tabs">
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
{t('tabList')}
</button>
<button type="button" className={tab === 'import' ? 'active' : ''} onClick={() => setTab('import')}>
{t('tabImport')}
</button>
<button type="button" className={tab === 'graph' ? 'active' : ''} onClick={() => setTab('graph')}>
{t('tabGraph')}
</button>
</nav>
</header>
{error && <div className="influences-error">{error}</div>}
{tab === 'list' && (
<section className="influences-panel">
<div className="influences-toolbar">
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t('searchPlaceholder')}
/>
<button type="button" onClick={() => void loadList()} disabled={loading}>
{loading ? t('loading') : t('refresh')}
</button>
<button type="button" onClick={() => setShowAdd((v) => !v)}>
{showAdd ? t('cancelAdd') : t('addEdge')}
</button>
<span className="influences-meta">
{t('total', { count: total })}
{filterArtistId ? ` · ${t('filtered')}` : ''}
</span>
{filterArtistId && (
<button
type="button"
onClick={() => {
setFilterArtistId(null);
}}
>
{t('clearFilter')}
</button>
)}
</div>
{showAdd && (
<div className="influences-add">
<label>
{t('subjectPainting')}
<input
value={addPaintingQuery}
onChange={(e) => {
setAddPaintingQuery(e.target.value);
setAddPaintingId(null);
}}
placeholder={t('searchPainting')}
/>
</label>
{searchHits.length > 0 && !addPaintingId && (
<ul className="influences-hits">
{searchHits.map((h) => (
<li key={`${h.type}-${h.id}`}>
<button
type="button"
onClick={() => {
setAddPaintingId(h.id);
setAddPaintingQuery(h.label);
}}
>
{h.label}
</button>
</li>
))}
</ul>
)}
<label>
{t('sourceType')}
<select
value={addSourceType}
onChange={(e) => {
setAddSourceType(e.target.value as 'artist' | 'painting' | 'movement');
setAddSourceId(null);
setAddSourceQuery('');
}}
>
<option value="artist">{t('typeArtist')}</option>
<option value="painting">{t('typePainting')}</option>
<option value="movement">{t('typeMovement')}</option>
</select>
</label>
<label>
{t('sourceEntity')}
<input
value={addSourceQuery}
onChange={(e) => {
setAddSourceQuery(e.target.value);
setAddSourceId(null);
}}
placeholder={t('searchSource')}
/>
</label>
{sourceHits.length > 0 && !addSourceId && (
<ul className="influences-hits">
{sourceHits.map((h) => (
<li key={`${h.type}-${h.id}`}>
<button
type="button"
onClick={() => {
setAddSourceId(h.id);
setAddSourceQuery(h.label);
}}
>
{h.label}
</button>
</li>
))}
</ul>
)}
<label>
{t('notes')}
<textarea value={addNotes} onChange={(e) => setAddNotes(e.target.value)} rows={2} />
</label>
<button type="button" disabled={busy} onClick={() => void handleCreate()}>
{t('saveEdge')}
</button>
</div>
)}
<div className="influences-table-wrap">
<table className="influences-table">
<thead>
<tr>
<th>{t('colSubject')}</th>
<th>{t('colSource')}</th>
<th>{t('colType')}</th>
<th>{t('colNotes')}</th>
<th>{t('colActions')}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>
<button
type="button"
className="linkish"
onClick={() => {
setFilterArtistId(item.artistId);
setTab('graph');
void loadGraph(item.artistId);
}}
>
{item.artistName}
</button>
<div className="muted">{item.paintingTitle}</div>
</td>
<td>{item.sourceLabel || '—'}</td>
<td>{item.sourceType}</td>
<td className="notes-cell">{item.notes || '—'}</td>
<td>
<button type="button" className="danger" onClick={() => void handleDelete(item.id)}>
{t('delete')}
</button>
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr>
<td colSpan={5}>{t('noEdges')}</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
)}
{tab === 'import' && (
<section className="influences-panel">
<ol className="wizard-steps">
<li className={wizardStep === 'upload' ? 'active' : ''}>{t('stepUpload')}</li>
<li className={wizardStep === 'mapping' ? 'active' : ''}>{t('stepMapping')}</li>
<li className={wizardStep === 'preview' ? 'active' : ''}>{t('stepPreview')}</li>
<li className={wizardStep === 'done' ? 'active' : ''}>{t('stepDone')}</li>
</ol>
{(wizardStep === 'upload' || wizardStep === 'done') && (
<div className="wizard-block">
<p>{t('uploadHelp')}</p>
<input
type="file"
accept=".csv,.json,.xlsx,.xls,application/json,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(e) => void onFileChosen(e.target.files?.[0] || null)}
disabled={busy}
/>
{commitResult && (
<p className="influences-success">
{t('commitSummary', { inserted: commitResult.inserted, skipped: commitResult.skipped })}
</p>
)}
</div>
)}
{wizardStep === 'mapping' && parseResult && (
<div className="wizard-block">
<p>
{t('fileInfo', {
name: parseResult.filename,
rows: parseResult.rowCount,
format: parseResult.format,
})}
</p>
{alreadyImported && priorImport && (
<div className="influences-dup-warn">
<p>
{t('alreadyImportedWarn', {
when: new Date(priorImport.importedAt).toLocaleString(),
who: priorImport.username || '—',
file: priorImport.fileName || parseResult.filename,
match: priorImport.match === 'file' ? t('matchFile') : t('matchData'),
})}
</p>
</div>
)}
{parseResult.sheets && parseResult.sheets.length > 1 && (
<label>
{t('sheet')}
<select
value={selectedSheet || ''}
onChange={(e) => void reparseSheet(e.target.value)}
>
{parseResult.sheets.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
)}
<label>
{t('preset')}
<select
defaultValue={parseResult.suggestedPreset}
onChange={(e) => applyPreset(e.target.value)}
>
{parseResult.presets.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</label>
<table className="influences-table mapping-table">
<thead>
<tr>
<th>{t('column')}</th>
<th>{t('role')}</th>
<th>{t('sample')}</th>
</tr>
</thead>
<tbody>
{parseResult.columns.map((col) => (
<tr key={col}>
<td>{col}</td>
<td>
<select
value={mapping[col] || 'ignore'}
onChange={(e) => setMapping((m) => ({ ...m, [col]: e.target.value }))}
>
{ROLE_OPTIONS.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</td>
<td className="muted">
{parseResult.sampleRows[0]?.[col]?.slice(0, 80) || '—'}
</td>
</tr>
))}
</tbody>
</table>
<div className="wizard-actions">
<button type="button" onClick={() => setWizardStep('upload')}>
{t('backStep')}
</button>
<button type="button" disabled={busy || !parseResult.rows} onClick={() => void runPreview()}>
{t('runPreview')}
</button>
</div>
</div>
)}
{wizardStep === 'preview' && preview && (
<div className="wizard-block">
<p>
{t('previewCounts', {
create: preview.counts.willCreate,
skip: preview.counts.willSkip,
errors: preview.counts.errors,
proposals: preview.counts.proposals,
})}
</p>
{alreadyImported && priorImport && (
<div className="influences-dup-warn">
<p>
{t('alreadyImportedWarn', {
when: new Date(priorImport.importedAt).toLocaleString(),
who: priorImport.username || '—',
file: priorImport.fileName || parseResult?.filename || '—',
match: priorImport.match === 'file' ? t('matchFile') : t('matchData'),
})}
</p>
<label className="influences-force">
<input
type="checkbox"
checked={forceImport}
onChange={(e) => setForceImport(e.target.checked)}
/>
{t('forceImport')}
</label>
</div>
)}
{preview.warnings.length > 0 && (
<details open={preview.warnings.length < 30}>
<summary>
{t('warnings', { count: preview.warnings.length })}
</summary>
<ul className="warnings-list">
{preview.warnings.slice(0, 80).map((w, i) => (
<li key={`${w.rowIndex}-${i}`}>
#{w.rowIndex + 1}: {w.message}
</li>
))}
</ul>
</details>
)}
<div className="influences-table-wrap">
<table className="influences-table">
<thead>
<tr>
<th>{t('colAction')}</th>
<th>{t('colSubject')}</th>
<th>{t('colSource')}</th>
<th>{t('colType')}</th>
<th>{t('colDirection')}</th>
</tr>
</thead>
<tbody>
{createProposals.map((p: InfluenceImportProposal) => (
<tr key={p.edgeKey}>
<td>{p.action}</td>
<td>
{p.artistName}
<div className="muted">{p.paintingTitle}</div>
</td>
<td>{p.sourceLabel}</td>
<td>{p.sourceType}</td>
<td>{p.direction}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="wizard-actions">
<button type="button" onClick={() => setWizardStep('mapping')}>
{t('backStep')}
</button>
<button
type="button"
disabled={
busy
|| preview.counts.willCreate === 0
|| (alreadyImported && !forceImport)
}
onClick={() => void runCommit()}
>
{t('commitImport', { count: preview.counts.willCreate })}
</button>
</div>
</div>
)}
</section>
)}
{tab === 'graph' && (
<section className="influences-panel">
<div className="influences-toolbar">
<input
type="search"
value={graphArtistQuery}
onChange={(e) => setGraphArtistQuery(e.target.value)}
placeholder={t('searchArtist')}
/>
</div>
{graphArtistHits.length > 0 && (
<ul className="influences-hits">
{graphArtistHits.map((h) => (
<li key={h.id}>
<button type="button" onClick={() => void loadGraph(h.id)}>
{h.label}
</button>
</li>
))}
</ul>
)}
{graph && (
<>
{graphArtistId != null && (
<p className="muted">Artist id: {graphArtistId}</p>
)}
<InfluenceGraphSvg graph={graph} onSelectArtist={(id) => {
setFilterArtistId(id);
setTab('list');
}} />
</>
)}
{!graph && <p className="muted">{t('graphEmpty')}</p>}
</section>
)}
</div>
);
}
function InfluenceGraphSvg({
graph,
onSelectArtist,
}: {
graph: InfluenceGraph;
onSelectArtist: (artistId: number) => void;
}) {
const { t } = useTranslation('influences');
const width = 720;
const height = 420;
const cx = width / 2;
const cy = height / 2;
const focus = graph.nodes.find((n) => n.focus) || graph.nodes[0];
const others = graph.nodes.filter((n) => n.id !== focus?.id);
const positions = new Map<string, { x: number; y: number }>();
if (focus) positions.set(focus.id, { x: cx, y: cy });
others.forEach((n, i) => {
const angle = (Math.PI * 2 * i) / Math.max(others.length, 1) - Math.PI / 2;
const r = 140 + (i % 3) * 28;
positions.set(n.id, { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r });
});
return (
<div className="influences-graph">
<h2>{graph.focus.label}</h2>
<p className="muted">
{t('graphStats', { nodes: graph.nodes.length, edges: graph.edges.length })}
</p>
<svg viewBox={`0 0 ${width} ${height}`} className="influences-svg" role="img">
{graph.edges.map((e) => {
const a = positions.get(e.from);
const b = positions.get(e.to);
if (!a || !b) return null;
return (
<line
key={`${e.id}-${e.from}-${e.to}`}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
className={e.direction === 'influenced_by' ? 'edge-in' : 'edge-out'}
/>
);
})}
{graph.nodes.map((n) => {
const p = positions.get(n.id);
if (!p) return null;
return (
<g
key={n.id}
transform={`translate(${p.x},${p.y})`}
className={`node node-${n.type}${n.focus ? ' focus' : ''}`}
onClick={() => {
if (n.artistId) onSelectArtist(n.artistId);
}}
>
<circle r={n.focus ? 22 : 14} />
<title>{n.label}</title>
<text y={n.focus ? 36 : 28} textAnchor="middle">
{n.label.length > 28 ? `${n.label.slice(0, 26)}` : n.label}
</text>
</g>
);
})}
</svg>
</div>
);
}
+112 -1
View File
@@ -13,11 +13,13 @@
"compression": "^1.8.1",
"connect-pg-simple": "^10.0.0",
"cors": "^2.8.6",
"csv-parse": "^7.0.1",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-session": "^1.19.0",
"pg": "^8.21.0",
"sharp": "^0.35.1"
"sharp": "^0.35.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^3.1.14"
@@ -594,6 +596,15 @@
"node": ">= 0.6"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -741,6 +752,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -766,6 +790,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -889,6 +922,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/csv-parse": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.1.tgz",
"integrity": "sha512-+2z7Ar0APQ7Uu6fX4cn+pitRmxjZ1WPBcGmZFKmA74FCyi7Et/XZx8cjNQ5CjbZ4HCOxXCOpRBYvYH08Qa003A==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1140,6 +1191,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -2039,6 +2099,18 @@
"node": ">= 10.x"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -2168,12 +2240,51 @@
"node": ">= 0.8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+4 -2
View File
@@ -67,14 +67,16 @@
"type": "commonjs",
"dependencies": {
"bcryptjs": "^3.0.3",
"connect-pg-simple": "^10.0.0",
"compression": "^1.8.1",
"connect-pg-simple": "^10.0.0",
"cors": "^2.8.6",
"csv-parse": "^7.0.1",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-session": "^1.19.0",
"pg": "^8.21.0",
"sharp": "^0.35.1"
"sharp": "^0.35.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^3.1.14"
+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;