Add influence rework, image checkup, debug mode, and fetched paintings.
Support artist and movement influence links with web discovery, a developer checkup table with gallery/detail thumbnails, and debug image search with fix-it workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
0ece1195fa
commit
bf7db9b25e
+74
-2
@@ -1,12 +1,31 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pool = require('./db');
|
||||
const { savePaintingImages } = require('../scripts/image-fetcher');
|
||||
const {
|
||||
savePaintingImages,
|
||||
downloadImageToFile,
|
||||
generateThumbnailFromFull,
|
||||
pickExt,
|
||||
} = require('../scripts/image-fetcher');
|
||||
|
||||
const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || './data/images');
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
const inflight = new Map();
|
||||
|
||||
function safePaintingBase(artistName, title) {
|
||||
return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function unlinkIfExists(absPath) {
|
||||
if (absPath && fs.existsSync(absPath)) {
|
||||
try {
|
||||
fs.unlinkSync(absPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
@@ -135,4 +154,57 @@ async function ensurePaintingImages(paintingId, size = 'thumb') {
|
||||
return promise;
|
||||
}
|
||||
|
||||
module.exports = { ensurePaintingImages, preloadArtistImagesLocal, IMAGE_DIR };
|
||||
async function replacePaintingImageFromUrl(paintingId, imageUrl) {
|
||||
const result = await pool.query(
|
||||
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
|
||||
FROM paintings p
|
||||
JOIN artists a ON a.id = p.artist_id
|
||||
WHERE p.id = $1`,
|
||||
[paintingId]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Painting not found');
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
const safeBase = safePaintingBase(row.artist_name, row.title);
|
||||
const paintingsDir = path.join(IMAGE_DIR, 'paintings');
|
||||
const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs');
|
||||
if (!fs.existsSync(paintingsDir)) fs.mkdirSync(paintingsDir, { recursive: true });
|
||||
if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true });
|
||||
|
||||
const fullExt = pickExt(imageUrl);
|
||||
const fullDest = path.join(paintingsDir, safeBase + fullExt);
|
||||
const thumbDest = path.join(thumbsDir, safeBase + '_thumb.jpg');
|
||||
|
||||
unlinkIfExists(row.image_path ? path.join(IMAGE_DIR, row.image_path) : null);
|
||||
unlinkIfExists(row.thumbnail_path ? path.join(IMAGE_DIR, row.thumbnail_path) : null);
|
||||
unlinkIfExists(fullDest);
|
||||
unlinkIfExists(thumbDest);
|
||||
|
||||
await downloadImageToFile(imageUrl, fullDest, { force: true });
|
||||
|
||||
let thumbnailPath = null;
|
||||
try {
|
||||
await generateThumbnailFromFull(fullDest, thumbDest);
|
||||
thumbnailPath = path.join('paintings', 'thumbs', safeBase + '_thumb.jpg').replace(/\\/g, '/');
|
||||
} catch {
|
||||
thumbnailPath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
const imagePath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/');
|
||||
|
||||
await pool.query(
|
||||
`UPDATE paintings SET image_path = $1, thumbnail_path = $2 WHERE id = $3`,
|
||||
[imagePath, thumbnailPath, paintingId]
|
||||
);
|
||||
|
||||
return { imagePath, thumbnailPath };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensurePaintingImages,
|
||||
preloadArtistImagesLocal,
|
||||
replacePaintingImageFromUrl,
|
||||
IMAGE_DIR,
|
||||
};
|
||||
|
||||
+213
-43
@@ -5,7 +5,8 @@ const fs = require('fs');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = require('./db');
|
||||
const { ensurePaintingImages, preloadArtistImagesLocal, IMAGE_DIR } = require('./image-service');
|
||||
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, IMAGE_DIR } = require('./image-service');
|
||||
const { searchGoogleImagesFirst, fetchImageBuffer, pickExt } = require('../scripts/image-fetcher');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -14,7 +15,50 @@ app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use('/images', express.static(IMAGE_DIR));
|
||||
|
||||
// Timeline: eras + movements for a year range
|
||||
const INFLUENCE_LINKS_EXISTS = `
|
||||
EXISTS (
|
||||
SELECT 1 FROM painting_influence_sources pis
|
||||
WHERE pis.painting_id = p.id OR pis.source_painting_id = p.id
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM painting_influences pi
|
||||
WHERE pi.painting_id = p.id OR pi.influenced_by_painting_id = p.id
|
||||
)`;
|
||||
|
||||
const INFLUENCED_BY_SQL = `
|
||||
SELECT
|
||||
pis.source_type,
|
||||
pis.period_note,
|
||||
pis.period_start_year,
|
||||
pis.period_end_year,
|
||||
pis.notes,
|
||||
pis.source,
|
||||
pis.aspects,
|
||||
pis.quote,
|
||||
pis.source_author,
|
||||
pis.source_url,
|
||||
pis.confidence,
|
||||
pis.discovered_via,
|
||||
p.id,
|
||||
p.title,
|
||||
p.year,
|
||||
p.image_path,
|
||||
pa.name AS artist_name,
|
||||
pa.id AS artist_id,
|
||||
sa.id AS source_artist_id,
|
||||
sa.name AS source_artist_name,
|
||||
sa.portrait_path AS artist_portrait,
|
||||
m.id AS movement_id,
|
||||
m.name AS movement_name,
|
||||
m.color AS movement_color
|
||||
FROM painting_influence_sources pis
|
||||
LEFT JOIN paintings p ON pis.source_painting_id = p.id
|
||||
LEFT JOIN artists pa ON p.artist_id = pa.id
|
||||
LEFT JOIN artists sa ON pis.source_artist_id = sa.id
|
||||
LEFT JOIN art_movements m ON pis.source_movement_id = m.id
|
||||
WHERE pis.painting_id = $1
|
||||
ORDER BY
|
||||
CASE pis.source_type WHEN 'painting' THEN 1 WHEN 'artist' THEN 2 ELSE 3 END,
|
||||
COALESCE(p.year, pis.period_start_year) NULLS LAST`;
|
||||
app.get('/api/timeline', async (req, res) => {
|
||||
try {
|
||||
const { start, end } = req.query;
|
||||
@@ -131,29 +175,51 @@ app.get('/api/artists/:id/navigation', async (req, res) => {
|
||||
);
|
||||
};
|
||||
|
||||
const predecessorSql = `
|
||||
SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path,
|
||||
m.id AS movement_id, m.name AS movement_name, m.color AS movement_color
|
||||
FROM paintings p_work
|
||||
JOIN painting_influence_sources pis ON pis.painting_id = p_work.id
|
||||
LEFT JOIN paintings p_pred ON pis.source_painting_id = p_pred.id
|
||||
LEFT JOIN artists a ON a.id = COALESCE(p_pred.artist_id, pis.source_artist_id)
|
||||
LEFT JOIN art_movements m ON a.movement_id = m.id
|
||||
WHERE p_work.artist_id = $1 AND a.id IS NOT NULL AND a.id <> $1
|
||||
UNION
|
||||
SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path,
|
||||
m.id AS movement_id, m.name AS movement_name, m.color AS movement_color
|
||||
FROM painting_influences pi
|
||||
JOIN paintings p_work ON pi.painting_id = p_work.id
|
||||
JOIN paintings p_pred ON pi.influenced_by_painting_id = p_pred.id
|
||||
JOIN artists a ON p_pred.artist_id = a.id
|
||||
LEFT JOIN art_movements m ON a.movement_id = m.id
|
||||
WHERE p_work.artist_id = $1 AND a.id <> $1`;
|
||||
|
||||
const successorSql = `
|
||||
SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path,
|
||||
m.id AS movement_id, m.name AS movement_name, m.color AS movement_color
|
||||
FROM painting_influence_sources pis
|
||||
JOIN paintings p_src ON pis.source_painting_id = p_src.id
|
||||
JOIN paintings p_work ON pis.painting_id = p_work.id
|
||||
JOIN artists a ON p_work.artist_id = a.id
|
||||
LEFT JOIN art_movements m ON a.movement_id = m.id
|
||||
WHERE p_src.artist_id = $1 AND a.id <> $1
|
||||
UNION
|
||||
SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path,
|
||||
m.id AS movement_id, m.name AS movement_name, m.color AS movement_color
|
||||
FROM painting_influences pi
|
||||
JOIN paintings p_src ON pi.influenced_by_painting_id = p_src.id
|
||||
JOIN paintings p_work ON pi.painting_id = p_work.id
|
||||
JOIN artists a ON p_work.artist_id = a.id
|
||||
LEFT JOIN art_movements m ON a.movement_id = m.id
|
||||
WHERE p_src.artist_id = $1 AND a.id <> $1`;
|
||||
|
||||
const [predecessors, successors] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path,
|
||||
m.id AS movement_id, m.name AS movement_name, m.color AS movement_color
|
||||
FROM painting_influences pi
|
||||
JOIN paintings p_work ON pi.painting_id = p_work.id
|
||||
JOIN paintings p_pred ON pi.influenced_by_painting_id = p_pred.id
|
||||
JOIN artists a ON p_pred.artist_id = a.id
|
||||
LEFT JOIN art_movements m ON a.movement_id = m.id
|
||||
WHERE p_work.artist_id = $1 AND a.id <> $1
|
||||
ORDER BY m.name NULLS LAST, a.birth_year NULLS LAST, a.name`,
|
||||
`SELECT * FROM (${predecessorSql}) u ORDER BY movement_name NULLS LAST, birth_year NULLS LAST, name`,
|
||||
[artistId]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path,
|
||||
m.id AS movement_id, m.name AS movement_name, m.color AS movement_color
|
||||
FROM painting_influences pi
|
||||
JOIN paintings p_src ON pi.influenced_by_painting_id = p_src.id
|
||||
JOIN paintings p_work ON pi.painting_id = p_work.id
|
||||
JOIN artists a ON p_work.artist_id = a.id
|
||||
LEFT JOIN art_movements m ON a.movement_id = m.id
|
||||
WHERE p_src.artist_id = $1 AND a.id <> $1
|
||||
ORDER BY m.name NULLS LAST, a.birth_year NULLS LAST, a.name`,
|
||||
`SELECT * FROM (${successorSql}) u ORDER BY movement_name NULLS LAST, birth_year NULLS LAST, name`,
|
||||
[artistId]
|
||||
),
|
||||
]);
|
||||
@@ -186,10 +252,7 @@ app.get('/api/artists/:id', async (req, res) => {
|
||||
),
|
||||
pool.query(
|
||||
`SELECT p.*,
|
||||
EXISTS (
|
||||
SELECT 1 FROM painting_influences pi
|
||||
WHERE pi.painting_id = p.id OR pi.influenced_by_painting_id = p.id
|
||||
) AS has_influence_links
|
||||
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
|
||||
FROM paintings p
|
||||
WHERE p.artist_id = $1
|
||||
ORDER BY p.sort_order, p.year`,
|
||||
@@ -212,6 +275,50 @@ app.get('/api/artists/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Painting image checkup (developer audit table) — must be before /api/paintings/:id
|
||||
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
|
||||
FROM paintings p
|
||||
JOIN artists a ON a.id = p.artist_id
|
||||
ORDER BY a.name, p.year NULLS LAST, p.title`
|
||||
);
|
||||
|
||||
const paintings = rows.map((row) => {
|
||||
const galleryFile = row.image_path || row.thumbnail_path || null;
|
||||
const detailFile = row.image_path || row.thumbnail_path || null;
|
||||
const detailOnDemand = !detailFile;
|
||||
|
||||
const galleryFileExists = galleryFile
|
||||
? fs.existsSync(path.join(IMAGE_DIR, galleryFile))
|
||||
: false;
|
||||
const detailFileExists = detailFile
|
||||
? fs.existsSync(path.join(IMAGE_DIR, detailFile))
|
||||
: false;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
artist_name: row.artist_name,
|
||||
year: row.year,
|
||||
gallery_file: galleryFile,
|
||||
gallery_preview: row.thumbnail_path || galleryFile,
|
||||
detail_file: detailOnDemand ? `/api/paintings/${row.id}/image?size=full` : detailFile,
|
||||
detail_preview: row.thumbnail_path || (detailOnDemand ? null : detailFile),
|
||||
detail_on_demand: detailOnDemand,
|
||||
gallery_file_exists: galleryFileExists,
|
||||
detail_file_exists: detailOnDemand ? null : detailFileExists,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ paintings, total: paintings.length });
|
||||
} catch (err) {
|
||||
console.error('Checkup error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to load checkup data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Painting detail with influences
|
||||
app.get('/api/paintings/:id', async (req, res) => {
|
||||
try {
|
||||
@@ -219,10 +326,7 @@ app.get('/api/paintings/:id', async (req, res) => {
|
||||
|
||||
const painting = await pool.query(
|
||||
`SELECT p.*, a.name as artist_name, a.id as artist_id, a.portrait_path as artist_portrait,
|
||||
EXISTS (
|
||||
SELECT 1 FROM painting_influences pi
|
||||
WHERE pi.painting_id = p.id OR pi.influenced_by_painting_id = p.id
|
||||
) AS has_influence_links
|
||||
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
|
||||
FROM paintings p
|
||||
JOIN artists a ON p.artist_id = a.id
|
||||
WHERE p.id = $1`,
|
||||
@@ -234,22 +338,26 @@ app.get('/api/paintings/:id', async (req, res) => {
|
||||
}
|
||||
|
||||
const [influencedBy, influenced] = await Promise.all([
|
||||
pool.query(INFLUENCED_BY_SQL, [id]),
|
||||
pool.query(
|
||||
`SELECT pi.notes, pi.source, pi.aspects, pi.quote, pi.source_author, pi.source_url,
|
||||
p.id, p.title, p.year, p.image_path, a.name as artist_name, a.id as artist_id
|
||||
FROM painting_influences pi
|
||||
JOIN paintings p ON pi.influenced_by_painting_id = p.id
|
||||
JOIN artists a ON p.artist_id = a.id
|
||||
WHERE pi.painting_id = $1`,
|
||||
[id]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT pi.notes, pi.source, pi.aspects, pi.quote, pi.source_author, pi.source_url,
|
||||
p.id, p.title, p.year, p.image_path, a.name as artist_name, a.id as artist_id
|
||||
FROM painting_influences pi
|
||||
JOIN paintings p ON pi.painting_id = p.id
|
||||
JOIN artists a ON p.artist_id = a.id
|
||||
WHERE pi.influenced_by_painting_id = $1`,
|
||||
`SELECT * FROM (
|
||||
SELECT pi.notes, pi.source, pi.aspects, pi.quote, pi.source_author, pi.source_url,
|
||||
'painting' AS source_type,
|
||||
p.id, p.title, p.year, p.image_path, a.name as artist_name, a.id as artist_id
|
||||
FROM painting_influences pi
|
||||
JOIN paintings p ON pi.painting_id = p.id
|
||||
JOIN artists a ON p.artist_id = a.id
|
||||
WHERE pi.influenced_by_painting_id = $1
|
||||
UNION ALL
|
||||
SELECT pis.notes, pis.source, pis.aspects, pis.quote, pis.source_author, pis.source_url,
|
||||
'painting' AS source_type,
|
||||
p.id, p.title, p.year, p.image_path, a.name as artist_name, a.id as artist_id
|
||||
FROM painting_influence_sources pis
|
||||
JOIN paintings p ON pis.painting_id = p.id
|
||||
JOIN artists a ON p.artist_id = a.id
|
||||
WHERE pis.source_painting_id = $1 AND pis.source_type = 'painting'
|
||||
) influenced_works
|
||||
ORDER BY year NULLS LAST`,
|
||||
[id]
|
||||
),
|
||||
]);
|
||||
@@ -277,6 +385,68 @@ app.post('/api/artists/:id/preload-images', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Developer debug: Google Images first result for image audit
|
||||
app.get('/api/paintings/:id/debug-image-search', async (req, res) => {
|
||||
try {
|
||||
const paintingId = parseInt(req.params.id, 10);
|
||||
const result = await pool.query(
|
||||
`SELECT p.title, a.name AS artist_name
|
||||
FROM paintings p
|
||||
JOIN artists a ON a.id = p.artist_id
|
||||
WHERE p.id = $1`,
|
||||
[paintingId]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Painting not found' });
|
||||
}
|
||||
|
||||
const { title, artist_name: artistName } = result.rows[0];
|
||||
const search = await searchGoogleImagesFirst(artistName, title);
|
||||
res.json(search);
|
||||
} catch (err) {
|
||||
console.error('Debug image search error:', err.message);
|
||||
res.status(500).json({ error: 'Image search failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Developer debug: replace painting image with a search result URL
|
||||
app.post('/api/paintings/:id/fix-image', async (req, res) => {
|
||||
try {
|
||||
const paintingId = parseInt(req.params.id, 10);
|
||||
const imageUrl = req.body?.imageUrl;
|
||||
if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) {
|
||||
return res.status(400).json({ error: 'Valid imageUrl required' });
|
||||
}
|
||||
|
||||
const updated = await replacePaintingImageFromUrl(paintingId, imageUrl);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
console.error('Fix image error:', err.message);
|
||||
res.status(500).json({ error: err.message || 'Fix image failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy remote image for debug preview (avoids hotlink / CORS blocks)
|
||||
app.get('/api/debug/image-proxy', async (req, res) => {
|
||||
try {
|
||||
const imageUrl = req.query.url;
|
||||
if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) {
|
||||
return res.status(400).json({ error: 'Valid url query required' });
|
||||
}
|
||||
|
||||
const buffer = await fetchImageBuffer(imageUrl);
|
||||
const ext = pickExt(imageUrl).toLowerCase();
|
||||
const type =
|
||||
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
|
||||
res.setHeader('Content-Type', type);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
console.error('Image proxy error:', err.message);
|
||||
res.status(502).json({ error: 'Could not load preview image' });
|
||||
}
|
||||
});
|
||||
|
||||
// On-demand painting image (resolves, caches, serves thumb or full)
|
||||
app.get('/api/paintings/:id/image', async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user