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>
@@ -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 painting’s 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=…` |
|
||||
|
||||
@@ -98,6 +98,12 @@ Phases within an artist’s 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)`
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 Rublev’s 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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 474 KiB |
|
Before Width: | Height: | Size: 1013 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 7.3 MiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 588 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 5.7 MiB After Width: | Height: | Size: 363 KiB |
|
Before Width: | Height: | Size: 5.7 MiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 5.7 MiB After Width: | Height: | Size: 495 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 507 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 38 KiB |
@@ -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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||