Add checkup review flags, duplicate detection, and corrected painting images.

Introduce checkup_checked/checkup_fixed on paintings with Checkup page filters and API. Fixed paintings auto-mark as reviewed. Add find-duplicates tooling and document debug/checkup workflow. Include Botticelli and Michelangelo image fixes from checkup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-20 20:45:55 +03:00
co-authored by Cursor
parent 4683089495
commit 4c6acd5a3a
55 changed files with 750 additions and 39 deletions
+111
View File
@@ -204,6 +204,112 @@ Returns the image bytes with `Cache-Control: public, max-age=86400`, or `404` if
---
## Developer image audit
Routes for the **Checkup** page and **Debug mode** on painting detail. Register `GET /api/paintings/checkup` **before** `GET /api/paintings/:id` so `"checkup"` is not parsed as a painting id.
### `GET /api/paintings/checkup`
Full-catalog audit table for the Checkup UI.
**Response**
```json
{
"paintings": [
{
"id": 36,
"title": "Trinity",
"artist_name": "Andrei Rublev",
"year": 1411,
"gallery_file": "paintings/Andrei_Rublev_Trinity.jpg",
"gallery_preview": "paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg",
"detail_file": "paintings/Andrei_Rublev_Trinity.jpg",
"detail_preview": "paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg",
"detail_on_demand": false,
"gallery_file_exists": true,
"detail_file_exists": true,
"checked": false,
"fixed": false
}
],
"total": 1201
}
```
| Field | Meaning |
|-------|---------|
| `checked` | Reviewed in checkup workflow (`checkup_checked`) |
| `fixed` | Image replaced via Fix (`checkup_fixed`) |
| `detail_on_demand` | `true` when detail would use `GET /api/paintings/:id/image?size=full` |
| `gallery_file_exists` / `detail_file_exists` | Disk check under `IMAGE_DIR` |
---
### `PATCH /api/paintings/:id/checkup-flags`
Update review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required.
Setting `fixed: true` also sets `checked: true`. While `fixed` is true, the API rejects clearing `checked`.
**Response**
```json
{ "checked": true, "fixed": false }
```
---
### `GET /api/paintings/:id/debug-image-search`
Google-family image search for debug / checkup (Custom Search → Google Arts & Culture → scrape → DuckDuckGo). Used by **Search visible** on the Checkup page and the debug panel on painting detail.
**Response**
```json
{
"query": "Andrei Rublev Trinity painting",
"imageUrl": "https://…",
"source": "google-arts",
"pageUrl": "https://…"
}
```
---
### `POST /api/paintings/:id/fix-image`
Download a remote URL and replace the paintings local full image + thumbnail. Sets `checkup_fixed = true` and `checkup_checked = true`.
**Body**
```json
{ "imageUrl": "https://…" }
```
**Response**
```json
{
"imagePath": "paintings/Artist_Title.jpg",
"thumbnailPath": "paintings/thumbs/Artist_Title_thumb.jpg",
"fixed": true,
"checked": true
}
```
---
### `GET /api/debug/image-proxy`
Proxy a remote image URL for debug preview (avoids hotlink / CORS blocks in the browser).
**Query:** `url` — must be `http://` or `https://`
Returns image bytes with appropriate `Content-Type`.
---
## Frontend helpers
The React client wraps these endpoints in `client/src/api/client.ts`:
@@ -220,3 +326,8 @@ The React client wraps these endpoints in `client/src/api/client.ts`:
| `imageUrl(path)` | `/images/<path>` or placeholder |
| `galleryImageUrl(painting)` | Local thumb/full only (3D) |
| `paintingImageUrl(painting)` | Local file or on-demand API |
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
| `api.fixPaintingImage(id, imageUrl)` | `POST /api/paintings/:id/fix-image` |
| `debugImageProxyUrl(imageUrl)` | `GET /api/debug/image-proxy?url=…` |
+7 -1
View File
@@ -98,6 +98,12 @@ Phases within an artists career (e.g. “Blue Period”, “Roman Period”).
| `thumbnail_path` | VARCHAR(500) | Smaller variant for lists / 3D |
| `wikipedia_title` | VARCHAR(300) | Used by image fetcher |
| `sort_order` | INTEGER | |
| `checkup_checked` | BOOLEAN NOT NULL DEFAULT false | Reviewed in image checkup workflow (UI label: **Reviewed**) |
| `checkup_fixed` | BOOLEAN NOT NULL DEFAULT false | Image corrected via checkup / debug **Fix** |
When `checkup_fixed` is true, `checkup_checked` is set automatically and cannot be cleared until **Fixed** is off.
Applied by `npm run migrate:checkup-flags` (`db/migrate-checkup-flags.sql`).
### `painting_influences`
@@ -150,7 +156,7 @@ Used by painting detail API (`influencedBy`). Painting-type rows also feed `has_
## Indexes
- `artists(movement_id)`, `artists(century)`
- `paintings(artist_id)`, `paintings(period_id)`
- `paintings(artist_id)`, `paintings(period_id)`, `paintings(checkup_checked)`, `paintings(checkup_fixed)`
- `art_movements(era_id)`, `art_movements(start_year, end_year)`
- `painting_influences(painting_id)`, `painting_influences(influenced_by_painting_id)`
- `painting_influence_sources(painting_id)`, `painting_influence_sources(source_artist_id)`, `painting_influence_sources(source_movement_id)`
+20 -1
View File
@@ -36,11 +36,12 @@ Gallery/
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
│ │ └── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
│ └── dist/ # Production build (served by API when present)
├── scripts/ # Seed, bios, catalog expansion, image fetch
├── scripts/ # Seed, bios, catalog expansion, image fetch, checkup tools
│ ├── fetch-artist-bios.js
│ ├── expand-paintings.js
│ ├── famous-paintings-data.js
│ ├── fetch-missing-images.js
│ ├── find-duplicate-paintings.js
│ └── image-fetcher.js
├── data/images/ # Local portraits and paintings (+ thumbs/)
├── db/ # SQL schema and migrations (when present)
@@ -222,6 +223,24 @@ Opened from the 3D hall (click a frame) or from influence thumbnails on another
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering.
- **Influence data** is stored as directed links from paintings to sources (another painting, an artist, or a movement), with optional period fields and citation metadata (source author, quote, URL).
## Developer tools (image audit)
Optional workflow for curating local image files — not part of the public visitor experience.
| Feature | Where | Purpose |
|---------|--------|---------|
| **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail |
| **Checkup page** | Home header → **Checkup** (`CheckupPage.tsx`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
| **Debug panel** | Painting detail (bottom-left, when debug mode on) | Google-family image search preview + **Fix it** for the current work |
**Checkup columns:** Gallery and Detail thumbnails, Search (reference image), Fix (replace local file), **Reviewed** (`checkup_checked`), **Fixed** (`checkup_fixed`).
**Search visible** runs image search only for rows currently shown after text/filter — not automatically on page load. Fixing an image sets **Fixed** and **Reviewed**.
Run `npm run migrate:checkup-flags` once on existing databases. After server code changes, restart `npm run dev` so new routes (e.g. `PATCH …/checkup-flags`) are registered.
See [API.md](API.md#developer-image-audit) and [data-and-images.md](data-and-images.md#duplicate-paintings).
## Related docs
| Document | Contents |
+37
View File
@@ -36,6 +36,8 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
| `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API |
| `regenerate-thumbnails.js` | `npm run regenerate-thumbnails` | Rebuild thumbs from full images via `sharp` |
| `audit-painting-images.js` | `npm run audit-painting-images` | Detect thumb/full aspect-ratio mismatches |
| `find-duplicate-paintings.js` | `npm run find-duplicates` | Report exact and near-duplicate catalog rows |
| `migrate-checkup-flags.js` | `npm run migrate:checkup-flags` | Add `checkup_checked` / `checkup_fixed` columns |
## Typical workflow
@@ -305,3 +307,38 @@ Examples already in the repo:
- `Charing Cross Bridge` → Derain (not Monet)
After adding an override, delete any wrong cached file under `data/images/paintings/` and re-run fetch or call the on-demand image endpoint for that painting.
## Duplicate paintings
The catalog can contain the same work more than once — usually from a **double import** (identical artist + title + year + image) or **Wikipedia scrape variants** (different article titles for one icon, e.g. Andrei Rublevs Trinity).
### Find duplicates
```bash
npm run find-duplicates
```
Runs `scripts/find-duplicate-paintings.js`, which reports:
| Report | Rule |
|--------|------|
| **Exact duplicates** | Same `artist_id`, `title`, and `year` |
| **Normalized title duplicates** | Same artist, titles differing only in punctuation/spacing |
| **Rublev / Trinity cluster** | Known multi-entry example for manual merge |
As of a recent audit (~1200 paintings): **52 exact duplicate pairs** (52 removable rows), concentrated in **Hieronymus Bosch** (25), **Albrecht Dürer** (14), and **Domenico Ghirlandaio** (13). Duplicate copies typically share the same image file and have **no influence links**, so the higher id in each pair is safe to delete after review.
**Near-duplicates** (different titles, same work) need curator judgment — e.g. Rublev ids 36, 316, 320, 322, 323 all describe the Trinity icon under different Wikipedia labels; keep id **36** (`Trinity`, wiki `Trinity (Andrei Rublev)`).
`expand-paintings.js` skips inserts when normalized titles match, but duplicates can still appear if seed and expansion use different title strings or if influence discovery creates works independently.
## Debug and checkup image fix
When **Debug mode** is on (home header) or from the **Checkup** page:
1. **Search**`GET /api/paintings/:id/debug-image-search` tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` in `scripts/image-fetcher.js`).
2. **Fix**`POST /api/paintings/:id/fix-image` downloads the chosen URL via `replacePaintingImageFromUrl` in `server/image-service.js`, regenerates the thumbnail with `sharp`, and sets checkup flags.
Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load.
See [API.md](API.md#developer-image-audit) and [basics.md](basics.md#developer-tools-image-audit).
+7
View File
@@ -65,6 +65,7 @@ After a fresh seed, run these to match a fully populated local install:
npm run fetch-artist-bios # bio_short / bio_full from Wikipedia
npm run expand-catalog # famous works for artists below MIN_PAINTINGS
npm run update-influences # painting influence graph for detail view + hall exits
npm run migrate:checkup-flags # optional: review/fixed flags for Checkup page
npm run fetch-images -- --limit=50 # random sample; 10s max per painting (default)
npm run fetch-images -- --limit=50 --max-wait=120 # same batch size, longer lookup per work
cd client && npm run build && cd ..
@@ -112,6 +113,8 @@ Open http://localhost:3001 (or your configured `PORT`).
| `npm run fetch-artist-images` | `scripts/fetch-artist-images.js` | Backfill portrait files *(if present)* |
| `npm run sync-image-paths` | `scripts/sync-image-paths.js` | Align DB paths with disk *(if present)* |
| `npm run migrate:influence-sources` | `scripts/migrate-influence-sources.js` | Create `painting_influence_sources` + backfill legacy edges |
| `npm run migrate:checkup-flags` | `scripts/migrate-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `paintings` |
| `npm run find-duplicates` | `scripts/find-duplicate-paintings.js` | Report duplicate and near-duplicate painting rows |
| `npm run update-influences` | `scripts/update-influences.js` | Insert influence links (painting / artist / movement) from `art-influences-data.js` |
| `npm run update-influences -- --fetch-images` | ↑ | Also download images for newly created works |
| `npm run update-influences -- --discover` | ↑ | Curated pass + web discovery (Wikipedia, Wikidata, Met, art-history sites) |
@@ -127,6 +130,8 @@ These are checked in and maintained:
- `update-influences.js` + `art-influences-data.js` — influence graph (paintings, artists, movements)
- `influence-discovery.js` + `influence-resolver.js` — web discovery and polymorphic source resolution
- `fetch-missing-images.js` — batch image backfill
- `find-duplicate-paintings.js` — duplicate catalog audit
- `migrate-checkup-flags.js` — checkup workflow columns
- `regenerate-thumbnails.js`, `audit-painting-images.js`
These are referenced in `package.json` but may need to be restored from git history if missing locally: `seed-wikipedia.js`, `fetch-artist-images.js`, `sync-image-paths.js`.
@@ -162,3 +167,5 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run
| Default Vite page instead of gallery | `client/dist` missing or stale | `cd client && npm run build` |
| Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate |
| Wikipedia API rate limit during fetch | Too many requests in a row | Wait and re-run; scripts retry with backoff |
| Checkup **Reviewed** toggle returns 404 | Stale server process missing new routes | Restart `npm run dev` after pulling API changes |
| Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run find-duplicates`; merge or delete spare rows manually |
+19 -1
View File
@@ -67,6 +67,8 @@ export interface PaintingCheckupRow {
detail_on_demand: boolean;
gallery_file_exists: boolean;
detail_file_exists: boolean | null;
checked: boolean;
fixed: boolean;
}
export interface PaintingCheckupData {
@@ -108,10 +110,26 @@ export const api = {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Fix failed: ${res.status}`);
}
return res.json() as Promise<{ imagePath: string; thumbnailPath: string }>;
return res.json() as Promise<{ imagePath: string; thumbnailPath: string; fixed?: boolean; checked?: boolean }>;
}),
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
updatePaintingCheckupFlags: (
id: number,
flags: { checked?: boolean; fixed?: boolean }
) =>
fetch(`${API}/paintings/${id}/checkup-flags`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flags),
}).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<{ checked: boolean; fixed: boolean }>;
}),
};
export function debugImageProxyUrl(imageUrl: string): string {
+74
View File
@@ -65,6 +65,80 @@
font-size: 13px;
}
.checkup-search-visible-btn {
padding: 8px 14px;
border-radius: 6px;
border: 1px solid #e8a040;
background: rgba(232, 160, 64, 0.15);
color: #e8a040;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.checkup-search-visible-btn:hover:not(:disabled) {
background: rgba(232, 160, 64, 0.28);
}
.checkup-search-visible-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
border-color: rgba(201, 169, 110, 0.25);
color: rgba(201, 169, 110, 0.45);
background: transparent;
}
.checkup-filter-select-wrap {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: rgba(201, 169, 110, 0.75);
flex-shrink: 0;
}
.checkup-filter-select {
padding: 6px 8px;
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.3);
background: rgba(15, 15, 26, 0.9);
color: #e8d5b5;
font-size: 12px;
}
.checkup-flag-cell {
width: 96px;
}
.checkup-flag-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: rgba(232, 213, 181, 0.85);
cursor: pointer;
user-select: none;
}
.checkup-flag-label input {
accent-color: #e8a040;
}
.checkup-row-checked {
background: rgba(201, 169, 110, 0.04);
}
.checkup-row-done {
background: rgba(76, 175, 80, 0.06);
}
.checkup-table tbody tr.checkup-row-checked:hover,
.checkup-table tbody tr.checkup-row-done:hover {
background: rgba(201, 169, 110, 0.1);
}
.checkup-stats {
display: flex;
flex-wrap: wrap;
+160 -34
View File
@@ -19,7 +19,6 @@ type RowSearchState =
| { status: 'error' };
const SEARCH_CONCURRENCY = 3;
const FILTER_SEARCH_DEBOUNCE_MS = 350;
function previewSrc(
row: PaintingCheckupRow,
@@ -88,9 +87,17 @@ function SearchPreviewCell({
searchState,
onRetry,
}: {
searchState: RowSearchState;
searchState: RowSearchState | undefined;
onRetry: () => void;
}) {
if (!searchState) {
return (
<div className="checkup-thumb checkup-thumb-search checkup-thumb-empty">
<span></span>
</div>
);
}
if (searchState.status === 'loading' || searchState.status === 'idle') {
return (
<div className="checkup-thumb checkup-thumb-search checkup-thumb-loading">
@@ -144,13 +151,13 @@ function FixCell({
fixError,
onFix,
}: {
searchState: RowSearchState;
searchState: RowSearchState | undefined;
fixing: boolean;
fixError: string | null;
onFix: () => void;
}) {
const canFix =
searchState.status === 'done' && !!searchState.result.imageUrl && !fixing;
searchState?.status === 'done' && !!searchState.result.imageUrl && !fixing;
return (
<div className="checkup-fix-cell">
@@ -160,7 +167,7 @@ function FixCell({
onClick={onFix}
disabled={!canFix}
title={
searchState.status === 'done' && searchState.result.imageUrl
searchState?.status === 'done' && searchState.result.imageUrl
? 'Replace gallery and detail images with search result'
: 'Wait for search result'
}
@@ -168,10 +175,10 @@ function FixCell({
{fixing ? 'Replacing…' : 'Fix it'}
</button>
{fixError && <p className="checkup-fix-error">{fixError}</p>}
{(searchState.status === 'loading' || searchState.status === 'idle') && (
{searchState?.status === 'loading' || searchState?.status === 'idle' ? (
<p className="checkup-fix-hint">Waiting for search</p>
)}
{searchState.status === 'done' && !searchState.result.imageUrl && (
) : null}
{searchState?.status === 'done' && !searchState.result.imageUrl && (
<p className="checkup-fix-hint">No image to apply</p>
)}
</div>
@@ -182,15 +189,44 @@ function needsSearch(state: RowSearchState | undefined): boolean {
return !state || state.status === 'idle' || state.status === 'error';
}
type FlagFilter = 'all' | 'yes' | 'no';
function FlagCheckbox({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled?: boolean;
onChange: (next: boolean) => void;
}) {
return (
<label className="checkup-flag-label">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span>{label}</span>
</label>
);
}
export default function CheckupPage({ onBack, onOpenPainting }: Props) {
const [rows, setRows] = useState<PaintingCheckupRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [filterChecked, setFilterChecked] = useState<FlagFilter>('all');
const [filterFixed, setFilterFixed] = useState<FlagFilter>('all');
const [searchById, setSearchById] = useState<Record<number, RowSearchState>>({});
const [imageVersionById, setImageVersionById] = useState<Record<number, number>>({});
const [fixingId, setFixingId] = useState<number | null>(null);
const [fixErrorById, setFixErrorById] = useState<Record<number, string>>({});
const [flagSavingId, setFlagSavingId] = useState<number | null>(null);
const [queuePending, setQueuePending] = useState(0);
const searchByIdRef = useRef(searchById);
@@ -315,6 +351,8 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
gallery_file_exists: true,
detail_file_exists: true,
detail_on_demand: false,
fixed: true,
checked: true,
}
: r
)
@@ -331,32 +369,55 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
[fixingId, searchById]
);
const updateRowFlags = useCallback(
async (rowId: number, flags: { checked?: boolean; fixed?: boolean }) => {
setFlagSavingId(rowId);
try {
const updated = await api.updatePaintingCheckupFlags(rowId, flags);
setRows((list) =>
list.map((r) =>
r.id === rowId ? { ...r, checked: updated.checked, fixed: updated.fixed } : r
)
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not save checkup flags.');
} finally {
setFlagSavingId(null);
}
},
[]
);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(row) =>
row.title.toLowerCase().includes(q) ||
row.artist_name.toLowerCase().includes(q) ||
String(row.year ?? '').includes(q) ||
(row.gallery_file ?? '').toLowerCase().includes(q) ||
row.detail_file.toLowerCase().includes(q)
);
}, [rows, filter]);
const filteredIds = useMemo(() => filtered.map((row) => row.id), [filtered]);
useEffect(() => {
if (loading) return;
const timer = setTimeout(() => {
for (const rowId of filteredIds) {
enqueueSearch(rowId);
return rows.filter((row) => {
if (q) {
const matchesText =
row.title.toLowerCase().includes(q) ||
row.artist_name.toLowerCase().includes(q) ||
String(row.year ?? '').includes(q) ||
(row.gallery_file ?? '').toLowerCase().includes(q) ||
row.detail_file.toLowerCase().includes(q);
if (!matchesText) return false;
}
}, FILTER_SEARCH_DEBOUNCE_MS);
if (filterChecked === 'yes' && !row.checked) return false;
if (filterChecked === 'no' && row.checked) return false;
if (filterFixed === 'yes' && !row.fixed) return false;
if (filterFixed === 'no' && row.fixed) return false;
return true;
});
}, [rows, filter, filterChecked, filterFixed]);
return () => clearTimeout(timer);
}, [filteredIds, loading, enqueueSearch]);
const handleSearchVisible = useCallback(() => {
for (const row of filtered) {
enqueueSearch(row.id);
}
}, [filtered, enqueueSearch]);
const visibleNeedSearch = useMemo(
() => filtered.filter((row) => needsSearch(searchById[row.id])).length,
[filtered, searchById]
);
const searchStats = useMemo(() => {
let done = 0;
@@ -374,7 +435,9 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
const onDemand = rows.filter((r) => r.detail_on_demand).length;
const missingGallery = rows.filter((r) => r.gallery_file && !r.gallery_file_exists).length;
const missingDetail = rows.filter((r) => r.detail_file_exists === false).length;
return { noGallery, onDemand, missingGallery, missingDetail };
const checked = rows.filter((r) => r.checked).length;
const fixed = rows.filter((r) => r.fixed).length;
return { noGallery, onDemand, missingGallery, missingDetail, checked, fixed };
}, [rows]);
return (
@@ -386,7 +449,7 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
<div className="checkup-title-block">
<h1>Painting checkup</h1>
<p>
Compare gallery vs detail images; filtered rows are searched automatically.
Compare gallery vs detail images, then use Search visible to fetch reference images.
</p>
</div>
</header>
@@ -399,8 +462,43 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<button
type="button"
className="checkup-search-visible-btn"
onClick={handleSearchVisible}
disabled={loading || filtered.length === 0 || visibleNeedSearch === 0}
title="Run Google-family image search for all rows currently shown in the table"
>
Search visible{visibleNeedSearch > 0 ? ` (${visibleNeedSearch})` : ''}
</button>
<label className="checkup-filter-select-wrap">
<span>Checked</span>
<select
className="checkup-filter-select"
value={filterChecked}
onChange={(e) => setFilterChecked(e.target.value as FlagFilter)}
>
<option value="all">All</option>
<option value="yes">Checked</option>
<option value="no">Not checked</option>
</select>
</label>
<label className="checkup-filter-select-wrap">
<span>Fixed</span>
<select
className="checkup-filter-select"
value={filterFixed}
onChange={(e) => setFilterFixed(e.target.value as FlagFilter)}
>
<option value="all">All</option>
<option value="yes">Fixed</option>
<option value="no">Not fixed</option>
</select>
</label>
<div className="checkup-stats">
<span>{filtered.length} shown</span>
<span>{stats.checked} checked</span>
<span>{stats.fixed} fixed</span>
<span>
search {searchStats.done}/{filtered.length} done
{queuePending > 0 ? ` · ${queuePending} active` : ''}
@@ -429,13 +527,25 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
<th>Detail</th>
<th>Search</th>
<th>Fix</th>
<th>Checked</th>
<th>Fixed</th>
</tr>
</thead>
<tbody>
{filtered.map((row) => {
const searchState = searchById[row.id] ?? { status: 'idle' as const };
const searchState = searchById[row.id];
const flagsBusy = flagSavingId === row.id;
return (
<tr key={row.id}>
<tr
key={row.id}
className={
row.checked && row.fixed
? 'checkup-row-done'
: row.checked
? 'checkup-row-checked'
: undefined
}
>
<td>
{onOpenPainting ? (
<button
@@ -479,6 +589,22 @@ export default function CheckupPage({ onBack, onOpenPainting }: Props) {
onFix={() => handleFix(row)}
/>
</td>
<td className="checkup-flag-cell">
<FlagCheckbox
label="Reviewed"
checked={row.checked}
disabled={flagsBusy || row.fixed}
onChange={(checked) => updateRowFlags(row.id, { checked })}
/>
</td>
<td className="checkup-flag-cell">
<FlagCheckbox
label="Fixed"
checked={row.fixed}
disabled={flagsBusy}
onChange={(fixed) => updateRowFlags(row.id, { fixed })}
/>
</td>
</tr>
);
})}
Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1013 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 MiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+6
View File
@@ -0,0 +1,6 @@
-- Checkup workflow flags on paintings (image audit page)
ALTER TABLE paintings ADD COLUMN IF NOT EXISTS checkup_checked BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE paintings ADD COLUMN IF NOT EXISTS checkup_fixed BOOLEAN NOT NULL DEFAULT false;
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);
+2
View File
@@ -17,6 +17,8 @@
"expand-catalog": "node scripts/expand-paintings.js",
"update-influences": "node scripts/update-influences.js",
"migrate:influence-sources": "node scripts/migrate-influence-sources.js",
"migrate:checkup-flags": "node scripts/migrate-checkup-flags.js",
"find-duplicates": "node scripts/find-duplicate-paintings.js",
"discover-influences": "node scripts/update-influences.js --discover-only",
"server": "node server/index.js",
"dev:server": "nodemon server/index.js",
+36
View File
@@ -0,0 +1,36 @@
require('dotenv').config();
const pool = require('../server/db');
async function main() {
const { rows } = await pool.query(`
WITH dups AS (
SELECT artist_id, title, year, array_agg(id ORDER BY id) AS ids
FROM paintings
GROUP BY artist_id, title, year
HAVING COUNT(*) > 1
),
expanded AS (
SELECT artist_id, title, year, ids[1] AS keep_id, unnest(ids[2:]) AS drop_id
FROM dups
)
SELECT e.drop_id,
(SELECT COUNT(*) FROM painting_influences pi
WHERE pi.painting_id = e.drop_id OR pi.influenced_by_painting_id = e.drop_id) AS legacy_links,
(SELECT COUNT(*) FROM painting_influence_sources pis
WHERE pis.painting_id = e.drop_id OR pis.source_painting_id = e.drop_id) AS source_links
FROM expanded e
ORDER BY e.drop_id
`);
const withLinks = rows.filter((r) => Number(r.legacy_links) + Number(r.source_links) > 0);
console.log(`Exact-duplicate drop candidates: ${rows.length}`);
console.log(`With influence links (need merge before delete): ${withLinks.length}`);
if (withLinks.length) console.table(withLinks);
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+51
View File
@@ -0,0 +1,51 @@
require('dotenv').config();
const pool = require('../server/db');
async function main() {
const sameWiki = await pool.query(`
SELECT a.name AS artist, p.wikipedia_title, COUNT(*)::int AS cnt,
array_agg(p.id ORDER BY p.id) AS ids,
array_agg(p.title ORDER BY p.id) AS titles,
array_agg(COALESCE(p.year::text, '?') ORDER BY p.id) AS years
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.wikipedia_title IS NOT NULL AND p.wikipedia_title <> ''
GROUP BY a.id, a.name, p.wikipedia_title
HAVING COUNT(*) > 1
ORDER BY cnt DESC, a.name
LIMIT 30
`);
const sameImage = await pool.query(`
SELECT a.name AS artist, p.image_path, COUNT(*)::int AS cnt,
array_agg(p.id ORDER BY p.id) AS ids,
array_agg(p.title ORDER BY p.id) AS titles
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.image_path IS NOT NULL AND p.image_path <> ''
GROUP BY a.id, a.name, p.image_path
HAVING COUNT(*) > 1
ORDER BY cnt DESC, a.name
LIMIT 20
`);
console.log('=== Same Wikipedia title (different painting rows) ===');
console.log(`Groups: ${sameWiki.rows.length} shown (top 30)`);
for (const row of sameWiki.rows) {
console.log(`\n[${row.cnt}x] ${row.artist} — wiki: "${row.wikipedia_title}"`);
row.titles.forEach((t, i) => console.log(` id ${row.ids[i]}: "${t}" (${row.years[i]})`));
}
console.log('\n=== Same image file (different painting rows) ===');
for (const row of sameImage.rows) {
console.log(`\n[${row.cnt}x] ${row.artist}${row.image_path}`);
row.titles.forEach((t, i) => console.log(` id ${row.ids[i]}: "${t}"`));
}
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+124
View File
@@ -0,0 +1,124 @@
require('dotenv').config();
const pool = require('../server/db');
async function main() {
const exact = await pool.query(`
SELECT a.name AS artist, p.title, p.year, COUNT(*)::int AS cnt,
array_agg(p.id ORDER BY p.id) AS ids,
array_agg(COALESCE(p.image_path, '') ORDER BY p.id) AS image_paths,
array_agg(COALESCE(p.wikipedia_title, '') ORDER BY p.id) AS wiki_titles
FROM paintings p
JOIN artists a ON a.id = p.artist_id
GROUP BY a.id, a.name, p.title, p.year
HAVING COUNT(*) > 1
ORDER BY cnt DESC, a.name, p.title
`);
const normalized = await pool.query(`
SELECT a.name AS artist,
lower(regexp_replace(regexp_replace(p.title, '[^a-zA-Z0-9 ]', '', 'g'), '\\s+', ' ', 'g')) AS norm_title,
COUNT(*)::int AS cnt,
array_agg(p.id ORDER BY p.id) AS ids,
array_agg(p.title ORDER BY p.id) AS titles,
array_agg(COALESCE(p.year::text, '?') ORDER BY p.id) AS years
FROM paintings p
JOIN artists a ON a.id = p.artist_id
GROUP BY a.id, a.name, norm_title
HAVING COUNT(*) > 1
ORDER BY cnt DESC, a.name, norm_title
`);
const rublev = await pool.query(`
SELECT p.id, p.title, p.year, p.image_path, p.wikipedia_title,
p.checkup_checked, p.checkup_fixed
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE a.name ILIKE '%rublev%'
OR p.title ILIKE '%trinity%'
ORDER BY a.name, p.title, p.id
`);
const stats = await pool.query(`
SELECT COUNT(*)::int AS dup_groups,
COALESCE(SUM(cnt - 1), 0)::int AS extra_rows
FROM (
SELECT COUNT(*)::int AS cnt
FROM paintings
GROUP BY artist_id, title, year
HAVING COUNT(*) > 1
) d
`);
const byArtist = await pool.query(`
SELECT a.name, COUNT(*)::int AS dup_groups, SUM(cnt - 1)::int AS extra_rows
FROM (
SELECT artist_id, title, year, COUNT(*)::int AS cnt
FROM paintings GROUP BY artist_id, title, year HAVING COUNT(*) > 1
) d
JOIN artists a ON a.id = d.artist_id
GROUP BY a.id, a.name ORDER BY extra_rows DESC
`);
const rublevCluster = await pool.query(`
SELECT p.id, p.title, p.year, p.wikipedia_title, p.image_path,
(SELECT COUNT(*) FROM painting_influences pi
WHERE pi.painting_id = p.id OR pi.influenced_by_painting_id = p.id) AS influence_edges,
(SELECT COUNT(*) FROM painting_influence_sources pis
WHERE pis.painting_id = p.id OR pis.source_painting_id = p.id) AS source_edges
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE a.name ILIKE '%rublev%'
AND (p.title ILIKE '%trinity%' OR p.title ILIKE '%hospitality%')
ORDER BY p.id
`);
const total = await pool.query('SELECT COUNT(*)::int AS n FROM paintings');
console.log('=== Summary ===');
console.log(`Total paintings: ${total.rows[0].n}`);
console.log(`Exact duplicate groups: ${stats.rows[0].dup_groups} (${stats.rows[0].extra_rows} rows could be removed)`);
console.log('\nArtists with exact duplicates:');
for (const row of byArtist.rows) {
console.log(` ${row.extra_rows} extra — ${row.name} (${row.dup_groups} duplicated works)`);
}
console.log('\n=== Exact duplicates (same artist + title + year) ===');
console.log(`Groups: ${exact.rows.length}`);
for (const row of exact.rows) {
console.log(`\n[${row.cnt}x] ${row.artist} — "${row.title}" (${row.year ?? '?'})`);
console.log(` ids: ${row.ids.join(', ')}`);
console.log(` images: ${row.image_paths.join(' | ')}`);
if (row.wiki_titles.some(Boolean)) console.log(` wiki: ${row.wiki_titles.join(' | ')}`);
}
console.log('\n=== Normalized title duplicates (same artist, similar title) ===');
console.log(`Groups: ${normalized.rows.length}`);
for (const row of normalized.rows) {
const alreadyExact = exact.rows.some(
(e) => e.artist === row.artist && e.cnt === row.cnt && e.ids.join() === row.ids.join()
);
if (alreadyExact) continue;
console.log(`\n[${row.cnt}x] ${row.artist}`);
row.titles.forEach((t, i) => console.log(` id ${row.ids[i]}: "${t}" (${row.years[i]})`));
}
console.log('\n=== Rublev Trinity / Hospitality cluster (likely same icon) ===');
for (const row of rublevCluster.rows) {
console.log(` id ${row.id}: "${row.title}" (${row.year ?? '?'})`);
console.log(` wiki: ${row.wikipedia_title || '—'}`);
console.log(` image: ${row.image_path || '—'}`);
console.log(` influence links: ${row.influence_edges} legacy, ${row.source_edges} sources`);
}
console.log('\n=== All Rublev / Trinity title matches ===');
for (const row of rublev.rows) {
console.log(` id ${row.id}: "${row.title}" (${row.year ?? '?'}) — ${row.image_path || 'no image'}`);
}
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+26
View File
@@ -0,0 +1,26 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pool = require('../server/db');
async function main() {
const sqlPath = path.join(__dirname, '../db/migrate-checkup-flags.sql');
const sql = fs.readFileSync(sqlPath, 'utf8');
await pool.query(sql);
await pool.query(
`UPDATE paintings SET checkup_checked = true WHERE checkup_fixed = true AND NOT checkup_checked`
);
const { rows } = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE checkup_checked)::int AS checked,
COUNT(*) FILTER (WHERE checkup_fixed)::int AS fixed
FROM paintings
`);
console.log(`checkup flags ready (${rows[0].checked} checked, ${rows[0].fixed} fixed)`);
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+70 -2
View File
@@ -279,7 +279,9 @@ app.get('/api/artists/:id', async (req, res) => {
app.get('/api/paintings/checkup', async (_req, res) => {
try {
const { rows } = await pool.query(
`SELECT p.id, p.title, p.year, p.image_path, p.thumbnail_path, a.name AS artist_name
`SELECT p.id, p.title, p.year, p.image_path, p.thumbnail_path,
p.checkup_checked AS checked, p.checkup_fixed AS fixed,
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.title`
@@ -309,6 +311,8 @@ app.get('/api/paintings/checkup', async (_req, res) => {
detail_on_demand: detailOnDemand,
gallery_file_exists: galleryFileExists,
detail_file_exists: detailOnDemand ? null : detailFileExists,
checked: !!row.checked,
fixed: !!row.fixed,
};
});
@@ -319,6 +323,66 @@ app.get('/api/paintings/checkup', async (_req, res) => {
}
});
// Update checkup workflow flags (checked / fixed)
app.patch('/api/paintings/:id/checkup-flags', async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const { checked, fixed } = req.body ?? {};
if (checked === undefined && fixed === undefined) {
return res.status(400).json({ error: 'Provide checked and/or fixed boolean' });
}
if (checked !== undefined && typeof checked !== 'boolean') {
return res.status(400).json({ error: 'checked must be a boolean' });
}
if (fixed !== undefined && typeof fixed !== 'boolean') {
return res.status(400).json({ error: 'fixed must be a boolean' });
}
const current = await pool.query(
`SELECT checkup_checked, checkup_fixed FROM paintings WHERE id = $1`,
[paintingId]
);
if (current.rows.length === 0) {
return res.status(404).json({ error: 'Painting not found' });
}
const willBeFixed =
fixed !== undefined ? fixed : !!current.rows[0].checkup_fixed;
let nextChecked = checked;
if (willBeFixed) {
nextChecked = true;
}
const sets = [];
const params = [];
if (fixed !== undefined) {
params.push(fixed);
sets.push(`checkup_fixed = $${params.length}`);
}
if (nextChecked !== undefined) {
params.push(nextChecked);
sets.push(`checkup_checked = $${params.length}`);
}
params.push(paintingId);
const result = await pool.query(
`UPDATE paintings SET ${sets.join(', ')}
WHERE id = $${params.length}
RETURNING checkup_checked AS checked, checkup_fixed AS fixed`,
params
);
res.json({
checked: !!result.rows[0].checked,
fixed: !!result.rows[0].fixed,
});
} catch (err) {
console.error('Checkup flags error:', err.message);
res.status(500).json({ error: 'Failed to update checkup flags' });
}
});
// Painting detail with influences
app.get('/api/paintings/:id', async (req, res) => {
try {
@@ -419,7 +483,11 @@ app.post('/api/paintings/:id/fix-image', async (req, res) => {
}
const updated = await replacePaintingImageFromUrl(paintingId, imageUrl);
res.json(updated);
await pool.query(
`UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`,
[paintingId]
);
res.json({ ...updated, fixed: true, checked: true });
} catch (err) {
console.error('Fix image error:', err.message);
res.status(500).json({ error: err.message || 'Fix image failed' });