Add PainterPalette integration, canonical DB schema, and influence UI fixes.

Integrate Inputs/PainterPalette.csv for artist metadata and influence links, add db/schema.sql with server/migrate.js, letterbox influence panel thumbnails, and refresh Titian/Pontormo images.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-22 12:09:47 +03:00
co-authored by Cursor
parent 4eead54062
commit b2cae284ac
41 changed files with 11270 additions and 14 deletions
+5 -2
View File
@@ -145,7 +145,8 @@ Full artist profile for the bio page and 3D gallery entry.
"bio_full": "Full Wikipedia lead section…",
"wikipedia_title": "Claude Monet",
"checkup_checked": false,
"checkup_fixed": false
"checkup_fixed": false,
"palette_metadata": { "nationality": "French", "styles": "Impressionism, …", "source": "PainterPalette.csv", ... }
},
"periods": [ { "id": 1, "name": "Milan Period", "start_year": 1482, "end_year": 1499, ... } ],
"paintings": [ { "id": 10, "title": "...", "year": 1498, "image_path": "...", "thumbnail_path": "...", "wikipedia_title": "...", "has_influence_links": true, "checkup_checked": false, "checkup_fixed": false, ... } ]
@@ -162,7 +163,7 @@ Each painting includes:
Populate biographies with `npm run fetch-artist-bios` (see [data-and-images.md](data-and-images.md)).
Artist objects also include `checkup_checked` and `checkup_fixed` (same semantics as paintings; gold portrait border when reviewed). Run `npm run migrate:artist-checkup-flags` on existing databases.
Artist objects also include `checkup_checked` and `checkup_fixed` (same semantics as paintings; gold portrait border when reviewed). After `npm run import-painter-palette`, **`palette_metadata`** holds PainterPalette enrichment (nationality, styles, occupations, raw influence fields, etc.). Run `npm run migrate:artist-checkup-flags` and `npm run migrate:artist-palette` on existing databases.
---
@@ -361,6 +362,8 @@ Painting detail with influence graph neighbours.
Both **`influencedBy`** and **`influenced`** are read from **`painting_influence_sources`** only (one row per edge). Painting-type sources appear in *Influenced By* when they are predecessors; in *Influenced* when this painting is the source of a later work. Artist and movement sources appear only in *Influenced By*. The legacy `painting_influences` table is not merged into these responses — it mirrors painting edges for scripts only.
In the UI, side-panel thumbnails use the paintings local image with **letterboxing** (`object-fit: contain`) so the full composition is visible. Click a thumbnail to open that works detail view.
---
## `GET /api/paintings/:id/image`
+2 -1
View File
@@ -72,8 +72,9 @@ Finer-grained styles (Impressionism, Cubism, Suprematism, …).
| `century` | INTEGER | Rounded century bucket for seeding limits |
| `checkup_checked` | BOOLEAN NOT NULL DEFAULT false | Portrait reviewed in debug workflow (gold border on bio when true) |
| `checkup_fixed` | BOOLEAN NOT NULL DEFAULT false | Portrait replaced, cleared, or uploaded via debug |
| `palette_metadata` | JSONB | Enrichment from `Inputs/PainterPalette.csv` (`npm run import-painter-palette`) |
Applied by `npm run migrate:artist-checkup-flags` (`db/migrate-artist-checkup-flags.sql`).
Applied by `npm run migrate:artist-checkup-flags` (`db/migrate-artist-checkup-flags.sql`) and `npm run migrate:artist-palette` (`db/migrate-artist-palette.sql`).
### `artist_periods`
+7 -2
View File
@@ -59,7 +59,11 @@ Gallery/
│ ├── fetch-missing-images.js
│ ├── find-duplicate-paintings.js
│ ├── audit-influence-duplicates.js
│ ├── painter-palette-lib.js
│ ├── import-painter-palette.js
│ └── image-fetcher.js
├── Inputs/ # External datasets (e.g. PainterPalette.csv)
├── Output/ # Generated exports (e.g. paintings.csv)
├── data/images/ # Local portraits and paintings (+ thumbs/)
├── db/ # SQL schema and migrations (when present)
├── deploy/ # Production nginx + systemd examples
@@ -272,7 +276,7 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl
| Layer | What you see |
|-------|----------------|
| **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails, artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) |
| **Detail** | Centre image, *Influenced By* (left) and *Influenced* (right) — painting thumbnails (full work visible, letterboxed), artist portraits, or movement swatches — position in catalog (e.g. `3 of 12`) |
| **Art history notes** | Numbered markers on the image (when positioned) plus a note list below — short citations from Gombrich, museum catalogs, Wikipedia, etc. (`painting_annotations` table) |
| **Fullscreen** | Click the centre image; `Escape` or click anywhere to return to detail only |
@@ -292,6 +296,7 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl
- **Catalog browsing** ( / arrow keys) walks the current artists works earliest → latest. It does **not** change the back target: after browsing several works, **Back to Gallery** still returns directly to the hall.
- **Influence links** push a new detail layer; **Back** from an influenced work returns to the painting you came from (and from there back to the gallery if applicable).
- Side-panel influence images use **`object-fit: contain`** so tall or wide works are not cropped (dark letterbox background).
- The 3D hall stays mounted in the background while detail is open so nothing is lost on return.
### Debug mode (developer)
@@ -307,7 +312,7 @@ When **Debug mode** is enabled from the home header, painting detail and artist
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
- **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit.
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering.
- **Influence data** is stored in **`painting_influence_sources`** (directed links from paintings to source paintings, artists, or movements), with optional period fields and citation metadata. Legacy `painting_influences` mirrors painting-to-painting edges for scripts only.
- **Influence data** is stored in **`painting_influence_sources`** (directed links from paintings to source paintings, artists, or movements), with optional period fields and citation metadata. Sources include curated scholarship (`art-influences-data.js`) and **PainterPalette** (`discovered_via = painter-palette`). Legacy `painting_influences` mirrors painting-to-painting edges for scripts only.
## Developer tools (image audit)
+45
View File
@@ -322,6 +322,51 @@ These live in `client/public/` (and `client/dist/` after build).
5. Run `npm run fetch-images -- --artist="…"` or rely on preload / on-demand sync.
6. Add influence rows via `npm run update-influences` / `art-influences-data.js` (writes both `painting_influence_sources` and legacy painting edges).
## PainterPalette external dataset
`Inputs/PainterPalette.csv` is a curated dataset (~10,000 painters) merging WikiArt, Art500k, and Wikidata with cleaned influence fields. The gallery integrates it **for existing DB artists only** (not a full catalog import).
### One-time setup
```bash
npm run migrate:artist-palette # adds artists.palette_metadata JSONB
npm run import-painter-palette # enrich + influence links
```
### What gets imported
| PainterPalette column | Gallery use |
|-----------------------|-------------|
| Nationality, gender, styles, birth/death places, occupations, … | Stored in `artists.palette_metadata` |
| `birth_year`, `death_year` | Fills missing DB years only |
| `Influencedby`, `Teachers` | **Artist** or **movement** sources on that artist's paintings |
| `Influencedon`, `Pupils` | Reverse **artist** sources on the pupil/successor's paintings |
Influence rows are written to `painting_influence_sources` with `discovered_via = painter-palette` and do not duplicate curated Gombrich edges (unique index + `ON CONFLICT DO NOTHING`).
Name matching uses normalized strings plus aliases in `scripts/painter-palette-lib.js` (e.g. `Bronzino``Agnolo Bronzino`, `J. M. W. Turner``J.M.W. Turner`). Museum names, WikiArt tags, and dimension strings are filtered out.
### Commands
```bash
npm run analyze-painter-palette # match report
npm run import-painter-palette -- --dry-run
npm run import-painter-palette -- --metadata-only
npm run import-painter-palette -- --influences-only
```
Re-run `import-painter-palette` after adding gallery artists or updating the CSV; existing palette influence rows are skipped if already present.
## Catalog export
Export the full painting catalog as CSV:
```bash
npm run export-paintings
```
Writes **`Output/paintings.csv`** with columns `artist`, `painting`, `year` (sorted by artist, year, title). The `Output/` folder is git-ignored by convention; regenerate after catalog changes.
## Image fetcher overrides
`scripts/image-fetcher.js` includes hand-maintained overrides for ambiguous Wikipedia titles and direct URLs (e.g. works whose Commons name does not match the article title, or when museum search returns the wrong work). Extend these maps when automated resolution fails:
+7
View File
@@ -168,6 +168,10 @@ Allow inbound **TCP 3520** on the gallery host if clients reach it directly on t
| `npm run migrate:checkup-flags` | `scripts/migrate-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `paintings` |
| `npm run migrate:artist-checkup-flags` | `scripts/migrate-artist-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `artists` (bio debug) |
| `npm run migrate:painting-annotations` | `scripts/migrate-painting-annotations.js` | Create `painting_annotations` table |
| `npm run migrate:artist-palette` | `scripts/migrate-artist-palette.js` | Add `palette_metadata` JSONB on `artists` |
| `npm run import-painter-palette` | `scripts/import-painter-palette.js` | Enrich artists + influence links from `Inputs/PainterPalette.csv` |
| `npm run analyze-painter-palette` | `scripts/analyze-painter-palette.js` | Report CSV ↔ gallery artist name matches |
| `npm run export-paintings` | `scripts/export-paintings-csv.js` | Write `Output/paintings.csv` (artist, painting, year) |
| `npm run update-painting-annotations` | `scripts/update-painting-annotations.js` | Load curated notes from `painting-annotations-data.js` |
| `npm run update-painting-annotations -- --wikipedia` | ↑ | Add intro sentences from each works Wikipedia page |
| `npm run update-painting-annotations -- --wikipedia --wiki-delay=3000` | ↑ | Slower Wikipedia pass when rate-limited (429) |
@@ -190,6 +194,8 @@ These are checked in and maintained:
- `fetch-missing-images.js` — batch image backfill
- `find-duplicate-paintings.js` — duplicate catalog audit
- `audit-influence-duplicates.js` — influence graph duplicate / mirror audit
- `painter-palette-lib.js`, `import-painter-palette.js`, `migrate-artist-palette.js`, `analyze-painter-palette.js` — PainterPalette CSV integration
- `export-paintings-csv.js` — catalog CSV export to `Output/`
- `migrate-checkup-flags.js` — checkup workflow columns (paintings)
- `migrate-artist-checkup-flags.js` — checkup workflow columns (artist portraits)
- `migrate-painting-annotations.js`, `update-painting-annotations.js`, `painting-annotations-data.js` — art-history notes on painting detail
@@ -240,3 +246,4 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run
| **Failed to load movement gallery** / `Cannot GET /api/movements/:id/gallery` | Stale server process missing route | Restart `npm run dev` or `npm run dev:server` after pulling API changes |
| Movement gallery shows generic cream walls | Stale client build | `cd client && npm run build`; hard-refresh browser |
| Windows overlap paintings in movement wing | Stale client | Rebuild client — windows are placed only on side walls in gaps between frames |
| Influence thumbnails cropped on painting detail | Stale client build | `npm run build` — panels use `object-fit: contain` for full image |
File diff suppressed because one or more lines are too long
+2
View File
@@ -40,6 +40,8 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
npm run fetch-artist-bios # Wikipedia biographies for all artists
npm run expand-catalog # add famous works for artists with thin catalogs
npm run update-influences # art-history lineage links between paintings
npm run migrate:artist-palette # optional: JSONB column for PainterPalette enrichment
npm run import-painter-palette # optional: metadata + influence links from Inputs/PainterPalette.csv
npm run migrate:checkup-flags # review/fixed flags for Checkup + debug mode (paintings)
npm run migrate:artist-checkup-flags # same flags for artist portraits (bio debug)
npm run migrate:painting-annotations # art-history notes on painting detail
+19 -9
View File
@@ -113,18 +113,26 @@
}
.influence-image-btn {
display: block;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0;
min-height: 120px;
max-height: 220px;
padding: 8px;
border: none;
background: #2a1f15;
cursor: pointer;
box-sizing: border-box;
}
.influence-image-btn img {
width: 100%;
height: 140px;
object-fit: cover;
width: auto;
height: auto;
max-width: 100%;
max-height: 200px;
object-fit: contain;
object-position: center;
display: block;
transition: opacity 0.2s;
}
@@ -180,9 +188,9 @@
}
.influence-card-artist .influence-portrait-btn img {
height: 120px;
object-fit: cover;
object-position: top center;
max-height: 160px;
object-fit: contain;
object-position: center top;
}
.influence-period {
@@ -290,9 +298,11 @@
.influence-card img {
width: 64px;
height: 64px;
object-fit: cover;
object-fit: contain;
object-position: center;
border-radius: 4px;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.25);
}
.influence-info {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 939 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 953 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 132 KiB

+6
View File
@@ -0,0 +1,6 @@
-- PainterPalette.csv enrichment fields on artists
ALTER TABLE artists
ADD COLUMN IF NOT EXISTS palette_metadata JSONB;
CREATE INDEX IF NOT EXISTS artists_palette_metadata_idx
ON artists USING gin (palette_metadata);
+159
View File
@@ -0,0 +1,159 @@
-- Virtual Art Gallery — canonical PostgreSQL schema
-- Applied by: npm run migrate (server/migrate.js)
-- Incremental ALTER migrations in db/migrate-*.sql run after this on existing databases.
CREATE TABLE IF NOT EXISTS historical_eras (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
start_year INTEGER NOT NULL,
end_year INTEGER NOT NULL,
start_definite BOOLEAN DEFAULT true,
end_definite BOOLEAN DEFAULT true,
description TEXT,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS art_movements (
id SERIAL PRIMARY KEY,
name VARCHAR(150) NOT NULL,
start_year INTEGER NOT NULL,
end_year INTEGER NOT NULL,
start_definite BOOLEAN DEFAULT true,
end_definite BOOLEAN DEFAULT true,
era_id INTEGER REFERENCES historical_eras(id),
description TEXT,
color VARCHAR(20) DEFAULT '#8B7355'
);
CREATE TABLE IF NOT EXISTS artists (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
birth_year INTEGER,
death_year INTEGER,
movement_id INTEGER REFERENCES art_movements(id),
portrait_path VARCHAR(500),
bio_short TEXT,
bio_full TEXT,
wikipedia_title VARCHAR(300),
century INTEGER,
checkup_checked BOOLEAN NOT NULL DEFAULT false,
checkup_fixed BOOLEAN NOT NULL DEFAULT false,
palette_metadata JSONB
);
CREATE TABLE IF NOT EXISTS artist_periods (
id SERIAL PRIMARY KEY,
artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
start_year INTEGER,
end_year INTEGER,
description TEXT,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS paintings (
id SERIAL PRIMARY KEY,
artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
period_id INTEGER REFERENCES artist_periods(id),
title VARCHAR(300) NOT NULL,
year INTEGER,
year_end INTEGER,
description TEXT,
image_path VARCHAR(500),
thumbnail_path VARCHAR(500),
wikipedia_title VARCHAR(300),
sort_order INTEGER DEFAULT 0,
checkup_checked BOOLEAN NOT NULL DEFAULT false,
checkup_fixed BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE IF NOT EXISTS painting_influences (
id SERIAL PRIMARY KEY,
painting_id INTEGER NOT NULL REFERENCES paintings(id) ON DELETE CASCADE,
influenced_by_painting_id INTEGER NOT NULL REFERENCES paintings(id) ON DELETE CASCADE,
notes TEXT,
source VARCHAR(500),
aspects TEXT,
quote TEXT,
source_author VARCHAR(200),
source_url VARCHAR(500),
UNIQUE(painting_id, influenced_by_painting_id)
);
CREATE TABLE IF NOT EXISTS painting_influence_sources (
id SERIAL PRIMARY KEY,
painting_id INTEGER NOT NULL REFERENCES paintings(id) ON DELETE CASCADE,
source_type VARCHAR(20) NOT NULL CHECK (source_type IN ('painting', 'artist', 'movement')),
source_painting_id INTEGER REFERENCES paintings(id) ON DELETE CASCADE,
source_artist_id INTEGER REFERENCES artists(id) ON DELETE CASCADE,
source_movement_id INTEGER REFERENCES art_movements(id) ON DELETE CASCADE,
period_note VARCHAR(240),
period_start_year INTEGER,
period_end_year INTEGER,
notes TEXT,
source VARCHAR(500),
aspects TEXT,
quote TEXT,
source_author VARCHAR(200),
source_url VARCHAR(500),
discovered_via VARCHAR(120),
confidence VARCHAR(20) NOT NULL DEFAULT 'curated',
CONSTRAINT painting_influence_sources_source_shape CHECK (
(source_type = 'painting' AND source_painting_id IS NOT NULL AND source_artist_id IS NULL AND source_movement_id IS NULL)
OR (source_type = 'artist' AND source_artist_id IS NOT NULL AND source_painting_id IS NULL AND source_movement_id IS NULL)
OR (source_type = 'movement' AND source_movement_id IS NOT NULL AND source_painting_id IS NULL AND source_artist_id IS NULL)
)
);
CREATE TABLE IF NOT EXISTS painting_annotations (
id SERIAL PRIMARY KEY,
painting_id INTEGER NOT NULL REFERENCES paintings(id) ON DELETE CASCADE,
label VARCHAR(80),
body TEXT NOT NULL,
category VARCHAR(30) NOT NULL DEFAULT 'subject',
pos_x NUMERIC(5, 2),
pos_y NUMERIC(5, 2),
source_author VARCHAR(200),
source VARCHAR(500),
source_url VARCHAR(500),
sort_order INTEGER NOT NULL DEFAULT 0,
confidence VARCHAR(20) NOT NULL DEFAULT 'curated'
);
CREATE INDEX IF NOT EXISTS idx_artists_movement ON artists(movement_id);
CREATE INDEX IF NOT EXISTS idx_artists_century ON artists(century);
CREATE INDEX IF NOT EXISTS artists_checkup_checked_idx ON artists (checkup_checked);
CREATE INDEX IF NOT EXISTS artists_checkup_fixed_idx ON artists (checkup_fixed);
CREATE INDEX IF NOT EXISTS artists_palette_metadata_idx ON artists USING gin (palette_metadata);
CREATE INDEX IF NOT EXISTS idx_paintings_artist ON paintings(artist_id);
CREATE INDEX IF NOT EXISTS idx_paintings_period ON paintings(period_id);
CREATE INDEX IF NOT EXISTS paintings_checkup_checked_idx ON paintings (checkup_checked);
CREATE INDEX IF NOT EXISTS paintings_checkup_fixed_idx ON paintings (checkup_fixed);
CREATE INDEX IF NOT EXISTS idx_movements_era ON art_movements(era_id);
CREATE INDEX IF NOT EXISTS idx_movements_years ON art_movements(start_year, end_year);
CREATE INDEX IF NOT EXISTS idx_influences_painting ON painting_influences(painting_id);
CREATE INDEX IF NOT EXISTS idx_influences_by ON painting_influences(influenced_by_painting_id);
CREATE UNIQUE INDEX IF NOT EXISTS painting_influence_sources_unique
ON painting_influence_sources (
painting_id,
source_type,
COALESCE(source_painting_id, 0),
COALESCE(source_artist_id, 0),
COALESCE(source_movement_id, 0)
);
CREATE INDEX IF NOT EXISTS painting_influence_sources_painting_idx
ON painting_influence_sources (painting_id);
CREATE INDEX IF NOT EXISTS painting_influence_sources_artist_idx
ON painting_influence_sources (source_artist_id);
CREATE INDEX IF NOT EXISTS painting_influence_sources_movement_idx
ON painting_influence_sources (source_movement_id);
CREATE INDEX IF NOT EXISTS painting_annotations_painting_idx
ON painting_annotations (painting_id);
+8
View File
@@ -0,0 +1,8 @@
-- Run as PostgreSQL superuser before first use if the app user cannot CREATE tables:
-- psql -h HOST -U postgres -d Gallery -f db/setup-admin.sql
GRANT CREATE ON SCHEMA public TO gallery;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO gallery;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO gallery;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO gallery;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO gallery;
+4
View File
@@ -20,6 +20,10 @@
"migrate:checkup-flags": "node scripts/migrate-checkup-flags.js",
"migrate:artist-checkup-flags": "node scripts/migrate-artist-checkup-flags.js",
"migrate:painting-annotations": "node scripts/migrate-painting-annotations.js",
"migrate:artist-palette": "node scripts/migrate-artist-palette.js",
"import-painter-palette": "node scripts/import-painter-palette.js",
"analyze-painter-palette": "node scripts/analyze-painter-palette.js",
"export-paintings": "node scripts/export-paintings-csv.js",
"update-painting-annotations": "node scripts/update-painting-annotations.js",
"find-duplicates": "node scripts/find-duplicate-paintings.js",
"audit-influence-duplicates": "node scripts/audit-influence-duplicates.js",
+27
View File
@@ -0,0 +1,27 @@
require('dotenv').config();
const pool = require('../server/db');
const { loadPainterPalette, findPaletteRow } = require('./painter-palette-lib');
async function main() {
const palette = loadPainterPalette();
const { rows: dbArtists } = await pool.query('SELECT name FROM artists ORDER BY name');
let matched = 0;
const unmatched = [];
for (const a of dbArtists) {
if (findPaletteRow(a.name, palette)) matched += 1;
else unmatched.push(a.name);
}
console.log('CSV rows:', palette.rowCount);
console.log('DB artists:', dbArtists.length);
console.log('Matched to PainterPalette:', matched, '/', dbArtists.length);
if (unmatched.length) console.log('Unmatched:', unmatched.join('; '));
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+36
View File
@@ -0,0 +1,36 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pool = require('../server/db');
function csvEscape(value) {
const v = value == null ? '' : String(value);
if (/[",\n\r]/.test(v)) return `"${v.replace(/"/g, '""')}"`;
return v;
}
async function main() {
const { rows } = await pool.query(`
SELECT a.name AS artist, p.title AS painting, p.year
FROM paintings p
JOIN artists a ON a.id = p.artist_id
ORDER BY a.name, p.year NULLS LAST, p.title
`);
const outDir = path.join(__dirname, '..', 'Output');
fs.mkdirSync(outDir, { recursive: true });
const header = 'artist,painting,year';
const lines = rows.map((r) =>
[csvEscape(r.artist), csvEscape(r.painting), csvEscape(r.year ?? '')].join(',')
);
const file = path.join(outDir, 'paintings.csv');
fs.writeFileSync(file, `${[header, ...lines].join('\n')}\n`, 'utf8');
console.log(`Wrote ${rows.length} rows to ${file}`);
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+237
View File
@@ -0,0 +1,237 @@
/**
* Integrate Inputs/PainterPalette.csv into existing gallery artists:
* - Enrich artists.palette_metadata (+ fill missing birth/death years)
* - Insert artist/movement influence sources from Influencedby, Teachers, Influencedon, Pupils
*
* Usage:
* npm run migrate:artist-palette
* npm run import-painter-palette # metadata + influences
* npm run import-painter-palette -- --dry-run
* npm run import-painter-palette -- --metadata-only
* npm run import-painter-palette -- --influences-only
*/
require('dotenv').config();
const pool = require('../server/db');
const {
loadPainterPalette,
findPaletteRow,
buildArtistResolver,
collectInfluenceSources,
} = require('./painter-palette-lib');
const { loadMovements, resolveAndInsertSource } = require('./influence-resolver');
const DRY_RUN = process.argv.includes('--dry-run');
const METADATA_ONLY = process.argv.includes('--metadata-only');
const INFLUENCES_ONLY = process.argv.includes('--influences-only');
const PALETTE_EDGE = {
notes: 'Artist relationship from PainterPalette dataset (WikiArt / Art500k / Wikidata enrichment).',
source: 'PainterPalette.csv',
source_author: 'PainterPalette',
confidence: 'discovered',
discovered_via: 'painter-palette',
};
function roleNote(role) {
if (role === 'teacher') return 'Listed as teacher in PainterPalette.';
if (role === 'pupil') return 'Listed as pupil in PainterPalette.';
if (role === 'influenced_on') return 'Listed as influenced by this artist in PainterPalette.';
return null;
}
async function main() {
const tableCheck = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = 'artists' AND column_name = 'palette_metadata'
) AS ok
`);
if (!tableCheck.rows[0]?.ok) {
console.error('Run npm run migrate:artist-palette first.');
process.exit(1);
}
const palette = loadPainterPalette();
console.log(`Loaded PainterPalette: ${palette.rowCount} painters`);
const { rows: dbArtists } = await pool.query(`
SELECT a.id, a.name, a.birth_year, a.death_year
FROM artists a
ORDER BY a.name
`);
const { rows: paintings } = await pool.query(`
SELECT p.id, p.year, p.artist_id, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
ORDER BY a.name, p.year NULLS LAST, p.id
`);
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);
}
const resolver = buildArtistResolver(dbArtists);
const movementsByName = await loadMovements(pool);
const stats = {
matched: 0,
metadataUpdated: 0,
yearsFilled: 0,
influencesAdded: 0,
influencesSkipped: 0,
influencesFailed: 0,
unresolved: new Set(),
};
const matchedPairs = [];
for (const artist of dbArtists) {
const row = findPaletteRow(artist.name, palette);
if (row) {
stats.matched += 1;
matchedPairs.push({ artist, row });
}
}
console.log(`Matched ${stats.matched} / ${dbArtists.length} gallery artists to PainterPalette`);
if (!INFLUENCES_ONLY) {
for (const { artist, row } of matchedPairs) {
const meta = row.metadata;
const updates = [];
const params = [];
let n = 1;
params.push(JSON.stringify(meta));
updates.push(`palette_metadata = $${n++}`);
if (!artist.birth_year && meta.birth_year) {
updates.push(`birth_year = $${n++}`);
params.push(Math.round(meta.birth_year));
stats.yearsFilled += 1;
}
if (!artist.death_year && meta.death_year) {
updates.push(`death_year = $${n++}`);
params.push(Math.round(meta.death_year));
stats.yearsFilled += 1;
}
params.push(artist.id);
const sql = `UPDATE artists SET ${updates.join(', ')} WHERE id = $${n}`;
if (DRY_RUN) {
console.log(`[dry-run] metadata ${artist.name}${row.artist}`);
} else {
await pool.query(sql, params);
stats.metadataUpdated += 1;
}
}
}
if (!METADATA_ONLY) {
async function applySourceToPaintings(paintingList, sourceRef, edge, subjectArtistId) {
if (!paintingList?.length) return;
for (const p of paintingList) {
if (DRY_RUN) {
stats.influencesAdded += 1;
continue;
}
const result = await resolveAndInsertSource(
pool,
{ id: p.id, year: p.year, artist_id: subjectArtistId },
sourceRef,
edge,
movementsByName,
false,
null,
null
);
if (result.inserted) stats.influencesAdded += 1;
else if (result.ok) stats.influencesSkipped += 1;
else if (result.reason !== 'self' && result.reason !== 'self artist') {
stats.influencesFailed += 1;
} else stats.influencesSkipped += 1;
}
}
for (const { artist, row } of matchedPairs) {
const subjectPaintings = paintingsByArtist.get(artist.id) || [];
if (!subjectPaintings.length) continue;
const sources = collectInfluenceSources(row);
for (const { raw, role } of sources) {
const resolved = resolver.resolve(raw);
if (!resolved) {
if (!isNoise(raw)) stats.unresolved.add(raw);
continue;
}
const extraNote = roleNote(role);
const edge = { ...PALETTE_EDGE, notes: extraNote ? `${PALETTE_EDGE.notes} ${extraNote}` : PALETTE_EDGE.notes };
if (role === 'influenced_by' || role === 'teacher') {
if (resolved.type === 'artist') {
await applySourceToPaintings(
subjectPaintings,
{ type: 'artist', artist: resolved.artist },
edge,
artist.id
);
} else if (resolved.type === 'movement') {
await applySourceToPaintings(
subjectPaintings,
{ type: 'movement', movement: resolved.movement },
edge,
artist.id
);
}
} else if (role === 'influenced_on' || role === 'pupil') {
if (resolved.type !== 'artist') continue;
const targetArtist = dbArtists.find((a) => a.id === resolved.id);
if (!targetArtist) continue;
const targetPaintings = paintingsByArtist.get(resolved.id) || [];
const reverseEdge = {
...edge,
notes: `${PALETTE_EDGE.notes} Reverse link: ${artist.name} listed as influence on ${resolved.artist}.`,
};
await applySourceToPaintings(
targetPaintings,
{ type: 'artist', artist: artist.name },
reverseEdge,
resolved.id
);
}
}
}
}
const { rows: totalSources } = await pool.query(
'SELECT COUNT(*)::int AS n FROM painting_influence_sources WHERE discovered_via = $1',
['painter-palette']
);
console.log('\n--- Summary ---');
if (!INFLUENCES_ONLY) {
console.log(`Metadata updated: ${stats.metadataUpdated}${DRY_RUN ? ' (dry-run)' : ''}`);
console.log(`Birth/death years filled: ${stats.yearsFilled}`);
}
if (!METADATA_ONLY) {
console.log(
`Influence rows attempted: ${stats.influencesAdded} inserted, ${stats.influencesSkipped} skipped, ${stats.influencesFailed} failed${DRY_RUN ? ' (dry-run counts inserts as attempts)' : ''}`
);
console.log(`Unresolved influence names (sample): ${[...stats.unresolved].slice(0, 15).join('; ')}`);
}
console.log(`Total painter-palette influence sources in DB: ${totalSources[0].n}`);
await pool.end();
}
function isNoise(name) {
const { isNoiseInfluenceName } = require('./painter-palette-lib');
return isNoiseInfluenceName(name);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+20
View File
@@ -0,0 +1,20 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pool = require('../server/db');
async function main() {
const sql = fs.readFileSync(path.join(__dirname, '../db/migrate-artist-palette.sql'), 'utf8');
await pool.query(sql);
const { rows } = await pool.query(`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'artists' AND column_name = 'palette_metadata'
`);
console.log(rows.length ? 'artists.palette_metadata ready' : 'migration may have failed');
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+279
View File
@@ -0,0 +1,279 @@
/**
* Shared PainterPalette.csv parsing, name matching, and influence cleaning.
*/
const fs = require('fs');
const path = require('path');
const CSV_PATH = path.join(__dirname, '../Inputs/PainterPalette.csv');
/** Gallery DB name → PainterPalette.csv artist column */
const DB_TO_PALETTE_NAME = {
Bronzino: 'Agnolo Bronzino',
Pontormo: 'Jacopo Pontormo',
'Ivan Klyun': 'Ivan Kliun',
'Henri Privat-Livemont': 'Henri Privat Livemont',
Zeuxis: 'Zeuxis',
Parrhasius: 'Parrhasius',
'Theophanes the Greek': 'Theophanes the Greek',
'J. M. W. Turner': 'J.M.W. Turner',
'Édouard Manet': 'Edouard Manet',
'Paul Cézanne': 'Paul Cezanne',
'Eugène Delacroix': 'Eugene Delacroix',
'François Boucher': 'Francois Boucher',
'Jean-Auguste-Dominique Ingres': 'Jean Auguste Dominique Ingres',
'Jean-Baptiste-Camille Corot': 'Jean-Baptiste-Camille Corot',
'Wassily Kandinsky': 'Wassily Kandinsky',
'Kazimir Malevich': 'Kazimir Malevich',
'Willem de Kooning': 'Willem de Kooning',
'René Magritte': 'Rene Magritte',
};
/** PainterPalette movement/style label → gallery art_movements.name */
const MOVEMENT_ALIASES = new Map(
Object.entries({
'byzantine art': 'Byzantine',
byzantine: 'Byzantine',
impressionism: 'Impressionism',
'post-impressionism': 'Post-Impressionism',
'neo-impressionism': 'Post-Impressionism',
cubism: 'Cubism',
'analytical cubism': 'Cubism',
'synthetic cubism': 'Cubism',
surrealism: 'Surrealism',
symbolism: 'Symbolism',
fauvism: 'Fauvism',
expressionism: 'Expressionism',
'abstract expressionism': 'Abstract Expressionism',
futurism: 'Futurism',
dada: 'Dada',
constructivism: 'Constructivism',
suprematism: 'Suprematism',
'pop art': 'Pop Art',
realism: 'Realism',
romanticism: 'Romanticism',
baroque: 'Baroque',
rococo: 'Rococo',
neoclassicism: 'Neoclassicism',
'art nouveau': 'Art Nouveau',
'art nouveau modern': 'Art Nouveau',
gothic: 'Gothic',
'high renaissance': 'High Renaissance',
'early renaissance': 'Early Renaissance',
'northern renaissance': 'Northern Renaissance',
mannerism: 'Mannerism',
'mannerism late renaissance': 'Mannerism',
'ancient greek roman': 'Ancient Greek & Roman',
'ancient greek & roman': 'Ancient Greek & Roman',
})
);
function parseCSVLine(line) {
const out = [];
let cur = '';
let q = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (c === '"') {
q = !q;
continue;
}
if (c === ',' && !q) {
out.push(cur);
cur = '';
continue;
}
cur += c;
}
out.push(cur);
return out;
}
function normalizeName(name) {
return (name || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function parseNameList(field) {
if (!field || !String(field).trim()) return [];
return String(field)
.split(',')
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean);
}
function isNoiseInfluenceName(name) {
const n = name.trim();
if (!n || n.length < 2) return true;
if (/\.(jpg|jpeg|png|gif)\b/i.test(n)) return true;
if (/^Artists\d/i.test(n)) return true;
if (/\b(museum|gallery|tretyakov|hermitage|louvre|national museum)\b/i.test(n)) return true;
if (/^(famous-people|male-portraits|female-portraits|winter|gardens-and-parks|rivers-and)/i.test(n)) return true;
if (/^[a-z0-9]+(-[a-z0-9]+){2,}$/.test(n) && !/\s/.test(n)) return true;
if (/^\d+(\.\d+)?\s*x\s*\d+(\.\d+)?\s*cm$/i.test(n)) return true;
if (/^[\d\s.x×cm]+$/i.test(n)) return true;
if (/^[^\x00-\x7F]+$/.test(n) && n.length < 40) return true;
if (n.length > 120) return true;
return false;
}
function paletteRowToMetadata(row, idx) {
const num = (v) => {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
return {
source: 'PainterPalette.csv',
nationality: row[idx.Nationality] || null,
citizenship: row[idx.citizenship] || null,
gender: row[idx.gender] || null,
styles: row[idx.styles] || null,
movement: row[idx.movement] || row[idx.ArtMovement] || null,
art500k_movements: row[idx.Art500k_Movements] || null,
birth_place: row[idx.birth_place] || null,
death_place: row[idx.death_place] || null,
birth_year: num(row[idx.birth_year]),
death_year: num(row[idx.death_year]),
first_year: num(row[idx.FirstYear]),
last_year: num(row[idx.LastYear]),
wikiart_pictures_count: num(row[idx.wikiart_pictures_count]),
occupations: row[idx.occupations] || null,
painting_school: row[idx.PaintingSchool] || null,
styles_extended: row[idx.styles_extended] || null,
influenced_by_raw: row[idx.Influencedby] || null,
influenced_on_raw: row[idx.Influencedon] || null,
teachers_raw: row[idx.Teachers] || null,
pupils_raw: row[idx.Pupils] || null,
};
}
function loadPainterPalette() {
const lines = fs.readFileSync(CSV_PATH, 'utf8').split(/\r?\n/).filter(Boolean);
const hdr = parseCSVLine(lines[0]);
const idx = Object.fromEntries(hdr.map((h, i) => [h, i]));
const byNorm = new Map();
const byExact = new Map();
for (let i = 1; i < lines.length; i++) {
const row = parseCSVLine(lines[i]);
const artist = row[idx.artist]?.trim();
if (!artist) continue;
const entry = { artist, row, idx, metadata: paletteRowToMetadata(row, idx) };
byExact.set(artist.toLowerCase(), entry);
byNorm.set(normalizeName(artist), entry);
}
return { idx, byNorm, byExact, rowCount: lines.length - 1 };
}
function findPaletteRow(dbName, palette) {
const alias = DB_TO_PALETTE_NAME[dbName];
if (alias) {
const hit = palette.byExact.get(alias.toLowerCase());
if (hit) return hit;
}
const exact = palette.byExact.get(dbName.toLowerCase());
if (exact) return exact;
const norm = palette.byNorm.get(normalizeName(dbName));
if (norm) return norm;
const dbNorm = normalizeName(dbName);
const parts = dbNorm.split(' ').filter(Boolean);
if (parts.length >= 2) {
const last = parts[parts.length - 1];
const candidates = [];
for (const [n, entry] of palette.byNorm) {
if (n.endsWith(` ${last}`) || n === last) candidates.push(entry);
}
if (candidates.length === 1) return candidates[0];
}
return null;
}
function buildArtistResolver(dbArtists) {
const byNorm = new Map();
const byLast = new Map();
for (const a of dbArtists) {
const norm = normalizeName(a.name);
if (!byNorm.has(norm)) byNorm.set(norm, a);
const parts = norm.split(' ').filter(Boolean);
const last = parts[parts.length - 1];
if (!byLast.has(last)) byLast.set(last, []);
byLast.get(last).push(a);
}
function resolve(rawName) {
const name = rawName.trim();
if (!name || isNoiseInfluenceName(name)) return null;
const alias = DB_TO_PALETTE_NAME[name] || name;
const norm = normalizeName(alias);
if (byNorm.has(norm)) return { type: 'artist', artist: byNorm.get(norm).name, id: byNorm.get(norm).id };
const parts = norm.split(' ').filter(Boolean);
if (parts.length >= 2) {
const last = parts[parts.length - 1];
const cands = (byLast.get(last) || []).filter((a) => {
const an = normalizeName(a.name);
return parts.every((p) => an.includes(p));
});
if (cands.length === 1) return { type: 'artist', artist: cands[0].name, id: cands[0].id };
}
const movementKey = norm.replace(/\s+/g, ' ');
if (MOVEMENT_ALIASES.has(movementKey)) {
return { type: 'movement', movement: MOVEMENT_ALIASES.get(movementKey) };
}
for (const [key, movement] of MOVEMENT_ALIASES) {
if (movementKey === key || movementKey.startsWith(`${key} `)) {
return { type: 'movement', movement };
}
}
return null;
}
return { resolve };
}
function collectInfluenceSources(paletteEntry) {
const { row, idx } = paletteEntry;
const sources = [];
const seen = new Set();
const addField = (field, role) => {
for (const raw of parseNameList(row[idx[field]])) {
const key = `${role}:${normalizeName(raw)}`;
if (seen.has(key)) continue;
seen.add(key);
sources.push({ raw, role });
}
};
addField('Teachers', 'teacher');
addField('Influencedby', 'influenced_by');
addField('Influencedon', 'influenced_on');
addField('Pupils', 'pupil');
return sources;
}
module.exports = {
CSV_PATH,
DB_TO_PALETTE_NAME,
MOVEMENT_ALIASES,
parseCSVLine,
normalizeName,
parseNameList,
isNoiseInfluenceName,
paletteRowToMetadata,
loadPainterPalette,
findPaletteRow,
buildArtistResolver,
collectInfluenceSources,
};
+45
View File
@@ -0,0 +1,45 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pool = require('./db');
const INCREMENTAL_MIGRATIONS = [
'migrate-checkup-flags.sql',
'migrate-artist-checkup-flags.sql',
'migrate-influence-sources.sql',
'migrate-painting-annotations.sql',
'migrate-artist-palette.sql',
];
async function applySqlFile(label, filePath) {
const sql = fs.readFileSync(filePath, 'utf8');
await pool.query(sql);
console.log(` ${label}`);
}
async function migrate() {
const dbDir = path.join(__dirname, '..', 'db');
const schemaPath = path.join(dbDir, 'schema.sql');
console.log('Applying db/schema.sql …');
await applySqlFile('schema.sql', schemaPath);
console.log('Applying incremental migrations …');
for (const file of INCREMENTAL_MIGRATIONS) {
const filePath = path.join(dbDir, file);
if (!fs.existsSync(filePath)) {
console.warn(` skipped (missing): ${file}`);
continue;
}
await applySqlFile(file, filePath);
}
console.log('Database migration complete.');
}
migrate()
.then(() => pool.end())
.catch((err) => {
console.error('Migration failed:', err.message);
pool.end().finally(() => process.exit(1));
});