Add debug More/Clear/Upload tools for paintings and artist portraits.

Extends the debug panel on painting detail and artist bio with a 20-result search picker, local image upload, and clear-to-empty-frame workflow, plus API routes, artist checkup migration, and documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-21 13:14:06 +03:00
co-authored by Cursor
parent b4425445bb
commit 0972b5df99
56 changed files with 1980 additions and 92 deletions
+233
View File
@@ -16,6 +16,10 @@ function safePaintingBase(artistName, title) {
return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function safeArtistPortraitBase(artistName) {
return artistName.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function unlinkIfExists(absPath) {
if (absPath && fs.existsSync(absPath)) {
try {
@@ -154,6 +158,183 @@ async function ensurePaintingImages(paintingId, size = 'thumb') {
return promise;
}
function pickExtFromMime(mimeType) {
const map = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
};
return map[String(mimeType || '').toLowerCase()] || '.jpg';
}
function unlinkPaintingFiles(row, safeBase) {
unlinkIfExists(row.image_path ? path.join(IMAGE_DIR, row.image_path) : null);
unlinkIfExists(row.thumbnail_path ? path.join(IMAGE_DIR, row.thumbnail_path) : null);
const paintingsDir = path.join(IMAGE_DIR, 'paintings');
const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs');
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.JPG']) {
unlinkIfExists(path.join(paintingsDir, safeBase + ext));
unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb' + ext));
unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb.jpg'));
}
}
function unlinkPortraitFiles(row, safeBase) {
unlinkIfExists(row.portrait_path ? path.join(IMAGE_DIR, row.portrait_path) : null);
const portraitsDir = path.join(IMAGE_DIR, 'portraits');
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.JPG']) {
unlinkIfExists(path.join(portraitsDir, safeBase + ext));
}
}
async function clearPaintingImage(paintingId) {
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);
unlinkPaintingFiles(row, safeBase);
await pool.query(
`UPDATE paintings SET image_path = NULL, thumbnail_path = NULL WHERE id = $1`,
[paintingId]
);
return { imagePath: null, thumbnailPath: null };
}
async function clearArtistPortrait(artistId) {
const result = await pool.query(
`SELECT id, name, portrait_path FROM artists WHERE id = $1`,
[artistId]
);
if (result.rows.length === 0) {
throw new Error('Artist not found');
}
const row = result.rows[0];
const safeBase = safeArtistPortraitBase(row.name);
unlinkPortraitFiles(row, safeBase);
await pool.query(`UPDATE artists SET portrait_path = NULL WHERE id = $1`, [artistId]);
return { portraitPath: null };
}
async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) {
if (!buffer?.length) {
throw new Error('Empty image data');
}
const sharp = require('sharp');
try {
await sharp(buffer).metadata();
} catch {
throw new Error('Invalid image file');
}
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 = pickExtFromMime(mimeType);
const fullDest = path.join(paintingsDir, safeBase + fullExt);
const thumbDest = path.join(thumbsDir, safeBase + '_thumb.jpg');
unlinkPaintingFiles(row, safeBase);
fs.writeFileSync(fullDest, buffer);
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 };
}
async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) {
if (!buffer?.length) {
throw new Error('Empty image data');
}
const sharp = require('sharp');
try {
await sharp(buffer).metadata();
} catch {
throw new Error('Invalid image file');
}
const result = await pool.query(
`SELECT id, name, portrait_path FROM artists WHERE id = $1`,
[artistId]
);
if (result.rows.length === 0) {
throw new Error('Artist not found');
}
const row = result.rows[0];
const safeBase = safeArtistPortraitBase(row.name);
const portraitsDir = path.join(IMAGE_DIR, 'portraits');
if (!fs.existsSync(portraitsDir)) fs.mkdirSync(portraitsDir, { recursive: true });
unlinkPortraitFiles(row, safeBase);
const jpgDest = path.join(portraitsDir, safeBase + '.jpg');
try {
await sharp(buffer)
.rotate()
.resize({ width: 900, height: 1100, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 88 })
.toFile(jpgDest);
} catch {
const fullExt = pickExtFromMime(mimeType);
const fullDest = path.join(portraitsDir, safeBase + fullExt);
fs.writeFileSync(fullDest, buffer);
const portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/');
await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]);
return { portraitPath };
}
const portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/');
await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]);
return { portraitPath };
}
async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
const result = await pool.query(
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
@@ -202,9 +383,61 @@ async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
return { imagePath, thumbnailPath };
}
async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
const result = await pool.query(
`SELECT id, name, portrait_path FROM artists WHERE id = $1`,
[artistId]
);
if (result.rows.length === 0) {
throw new Error('Artist not found');
}
const row = result.rows[0];
const safeBase = safeArtistPortraitBase(row.name);
const portraitsDir = path.join(IMAGE_DIR, 'portraits');
if (!fs.existsSync(portraitsDir)) fs.mkdirSync(portraitsDir, { recursive: true });
const fullExt = pickExt(imageUrl);
const fullDest = path.join(portraitsDir, safeBase + fullExt);
unlinkIfExists(row.portrait_path ? path.join(IMAGE_DIR, row.portrait_path) : null);
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) {
unlinkIfExists(path.join(portraitsDir, safeBase + ext));
}
await downloadImageForFix(imageUrl, fullDest, context);
let portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/');
const jpgDest = path.join(portraitsDir, safeBase + '.jpg');
try {
const sharp = require('sharp');
await sharp(fullDest)
.rotate()
.resize({ width: 900, height: 1100, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 88 })
.toFile(jpgDest);
if (fullDest !== jpgDest && fs.existsSync(fullDest)) {
fs.unlinkSync(fullDest);
}
portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/');
} catch {
// keep downloaded file as-is
}
await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]);
return { portraitPath };
}
module.exports = {
ensurePaintingImages,
preloadArtistImagesLocal,
replacePaintingImageFromUrl,
replaceArtistPortraitFromUrl,
clearPaintingImage,
clearArtistPortrait,
replacePaintingImageFromBuffer,
replaceArtistPortraitFromBuffer,
IMAGE_DIR,
};