Add curator Influences tool with import wizard, CRUD, and graph.
CSV/JSON/XLSX mapping wizard expands artist-level rows to all paintings, blocks duplicate file/data imports via content and payload hashes, and documents the workflow in influence-import.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
62d7ebbe6a
commit
48bd17e985
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`):**
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"showMoreDebug": "Показать больше (отладка)",
|
||||
"checkup": "Проверка",
|
||||
"translations": "Переводы",
|
||||
"influences": "Влияния",
|
||||
"curatorRequiredTitle": "Требуется доступ куратора",
|
||||
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
|
||||
"backToGalleryBtn": "Вернуться в галерею"
|
||||
|
||||
@@ -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}} рёбер"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Generated
+112
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user