/** * Bidirectional image harmonize (dev <-> prod) by file mtime, * then merge artists/paintings catalog rows (checkup flags + image paths), * then regenerate painting + portrait thumbnails on both image roots. * * Usage: * npm run harmonize:images * npm run harmonize:images -- --dry-run * npm run harmonize:images -- --skip-thumbnails * npm run harmonize:images -- --skip-db */ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { rootDir, confirmProdAction, PROD_DB_NAME } = require('./db-env'); const { loadHarmonizeConfig, prodImagesPath } = require('./lib/harmonize-config'); const { printCliResult } = require('./lib/cli-result'); const IMAGE_SUBDIRS = ['portraits', 'paintings']; const MTIME_TOLERANCE_MS = 1000; /** Tables that carry checkup flags and image/portrait path links. */ const IMAGE_CATALOG_TABLES = ['artists', 'paintings']; const THUMB_SCRIPTS = [ 'regenerate-thumbnails.js', 'regenerate-portrait-thumbs.js', ]; function parseArgs(argv) { const configArg = argv.find((a) => a.startsWith('--config=')); const preferArg = argv.find((a) => a.startsWith('--prefer=')); return { dryRun: argv.includes('--dry-run'), verbose: argv.includes('--verbose'), skipThumbnails: argv.includes('--skip-thumbnails'), skipDb: argv.includes('--skip-db'), prefer: preferArg ? preferArg.slice('--prefer='.length).trim().toLowerCase() : null, configPath: configArg ? configArg.slice('--config='.length) : null, }; } function resolveDevImageDir() { require('dotenv').config({ path: path.join(rootDir, '.env') }); return path.resolve(process.env.IMAGE_DIR || path.join(rootDir, 'data', 'images')); } function walkImageFiles(rootDir, baseRel = '') { const files = []; const abs = path.join(rootDir, baseRel); if (!fs.existsSync(abs)) return files; for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { const rel = baseRel ? path.join(baseRel, entry.name).replace(/\\/g, '/') : entry.name; if (entry.isDirectory()) { files.push(...walkImageFiles(rootDir, rel)); } else if (entry.isFile()) { files.push(rel); } } return files; } function collectRelativePaths(imageRoot) { const relPaths = new Set(); for (const sub of IMAGE_SUBDIRS) { const subRoot = path.join(imageRoot, sub); if (!fs.existsSync(subRoot)) continue; for (const rel of walkImageFiles(subRoot, sub)) { relPaths.add(rel.replace(/\\/g, '/')); } } return relPaths; } function statFile(imageRoot, relPath) { const abs = path.join(imageRoot, relPath); try { if (!fs.existsSync(abs)) return null; const st = fs.statSync(abs); if (!st.isFile()) return null; return { size: st.size, mtimeMs: st.mtimeMs }; } catch { return null; } } function ensureDirForFile(filePath) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } function copyFile(src, dest, dryRun) { if (dryRun) return; ensureDirForFile(dest); fs.copyFileSync(src, dest); } /** * Rebuild thumbs from full images for one environment. * Child scripts honour existing env (dotenv will not override DB_NAME / IMAGE_DIR). */ function regenerateThumbnails({ label, imageDir, database }) { console.log(`\n--- Thumbnails (${label}) ---`); console.log(` IMAGE_DIR=${imageDir}`); console.log(` DB_NAME=${database}`); const env = { ...process.env, IMAGE_DIR: imageDir, DB_NAME: database, }; for (const file of THUMB_SCRIPTS) { const scriptPath = path.join(__dirname, file); console.log(` Running ${file}…`); const result = spawnSync(process.execPath, [scriptPath], { cwd: rootDir, stdio: 'inherit', env, }); const code = result.status ?? 1; if (code !== 0) { throw new Error(`Thumbnail regeneration failed (${label}): ${file} exited ${code}`); } } } /** * Merge artists + paintings so checkup flags and image/portrait path links * travel with the file sync (harmonize:images alone used to leave DB stale). */ function syncImageCatalogRows({ dryRun, prefer, verbose }) { console.log('\n--- Catalog (artists + paintings checkup / image paths) ---'); const args = [ path.join(__dirname, 'harmonize-db.js'), `--tables=${IMAGE_CATALOG_TABLES.join(',')}`, ]; if (dryRun) args.push('--dry-run'); if (verbose) args.push('--verbose'); if (prefer === 'dev' || prefer === 'prod') args.push(`--prefer=${prefer}`); const result = spawnSync(process.execPath, args, { cwd: rootDir, stdio: 'inherit', env: { ...process.env, // Parent already confirmed prod writes for this harmonize:images run. CONFIRM_PROD: '1', }, }); const code = result.status ?? 1; if (code !== 0) { throw new Error(`Image catalog sync failed: harmonize-db.js exited ${code}`); } } function timestampSlug(date = new Date()) { const pad = (n) => String(n).padStart(2, '0'); return [ date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate()), '_', pad(date.getHours()), pad(date.getMinutes()), pad(date.getSeconds()), ].join(''); } async function main() { const options = parseArgs(process.argv.slice(2)); const config = loadHarmonizeConfig(options.configPath); const dryRun = options.dryRun || config.dryRun; const devDir = resolveDevImageDir(); const prodDir = prodImagesPath(config); if (!fs.existsSync(prodDir)) { throw new Error( `Prod image path not reachable: ${prodDir}. Map SMB share or set paths.prodImages in harmonize config.`, ); } if (!dryRun && process.env.CONFIRM_PROD !== '1') { await confirmProdAction( 'Harmonize will copy image files between dev and prod, merge artists/paintings checkup flags and image paths, then regenerate thumbnails on both sides (prod files and gallery_prod may be updated).', ); } const devPaths = collectRelativePaths(devDir); const prodPaths = collectRelativePaths(prodDir); const allPaths = new Set([...devPaths, ...prodPaths]); const stats = { devToProd: 0, prodToDev: 0, skipped: 0, actions: [], }; for (const rel of allPaths) { const devStat = statFile(devDir, rel); const prodStat = statFile(prodDir, rel); if (devStat && !prodStat) { stats.devToProd += 1; stats.actions.push({ path: rel, action: 'dev→prod copy' }); copyFile(path.join(devDir, rel), path.join(prodDir, rel), dryRun); continue; } if (prodStat && !devStat) { stats.prodToDev += 1; stats.actions.push({ path: rel, action: 'prod→dev copy' }); copyFile(path.join(prodDir, rel), path.join(devDir, rel), dryRun); continue; } if (!devStat || !prodStat) continue; const sameSize = devStat.size === prodStat.size; const mtimeClose = Math.abs(devStat.mtimeMs - prodStat.mtimeMs) <= MTIME_TOLERANCE_MS; if (sameSize && mtimeClose) { stats.skipped += 1; continue; } if (devStat.mtimeMs > prodStat.mtimeMs) { stats.devToProd += 1; stats.actions.push({ path: rel, action: 'dev→prod update' }); copyFile(path.join(devDir, rel), path.join(prodDir, rel), dryRun); } else if (prodStat.mtimeMs > devStat.mtimeMs) { stats.prodToDev += 1; stats.actions.push({ path: rel, action: 'prod→dev update' }); copyFile(path.join(prodDir, rel), path.join(devDir, rel), dryRun); } else { stats.skipped += 1; } } const reportDir = path.join(rootDir, 'db', 'SyncReports'); fs.mkdirSync(reportDir, { recursive: true }); const reportPath = path.join(reportDir, `harmonize_images_${timestampSlug()}.json`); const extraDetails = []; if (!options.skipDb) { syncImageCatalogRows({ dryRun, prefer: options.prefer, verbose: options.verbose, }); extraDetails.push( dryRun ? 'Catalog checkup/path sync previewed (artists, paintings; dry-run)' : 'Catalog checkup flags + image paths merged (artists, paintings)', ); } else { extraDetails.push('Catalog checkup/path sync skipped (--skip-db)'); } if (!dryRun && !options.skipThumbnails) { require('dotenv').config({ path: path.join(rootDir, '.env') }); const devDb = process.env.DB_NAME || 'gallery_dev'; regenerateThumbnails({ label: 'dev', imageDir: devDir, database: devDb, }); extraDetails.push(`Dev thumbs regenerated (${devDb} @ ${devDir})`); regenerateThumbnails({ label: 'prod', imageDir: prodDir, database: PROD_DB_NAME, }); extraDetails.push(`Prod thumbs regenerated (${PROD_DB_NAME} @ ${prodDir})`); } else if (options.skipThumbnails) { extraDetails.push('Thumbnails skipped (--skip-thumbnails)'); } else { extraDetails.push('Thumbnails skipped (dry-run)'); } fs.writeFileSync(reportPath, JSON.stringify({ generatedAt: new Date().toISOString(), dryRun, skipThumbnails: options.skipThumbnails, skipDb: options.skipDb, prefer: options.prefer, devDir, prodDir, stats: { devToProd: stats.devToProd, prodToDev: stats.prodToDev, skipped: stats.skipped, }, steps: extraDetails, actions: options.verbose ? stats.actions : stats.actions.slice(0, 500), }, null, 2)); const mode = dryRun ? ' (dry-run)' : ''; printCliResult({ script: 'harmonize-images', ok: true, summary: `Image harmonize complete${mode}: dev→prod ${stats.devToProd}, prod→dev ${stats.prodToDev}, skipped ${stats.skipped}.`, details: [ `Dev: ${devDir}`, `Prod: ${prodDir}`, `Report: ${reportPath}`, ...extraDetails, ], }); } main().catch((err) => { printCliResult({ script: 'harmonize-images', ok: false, summary: err.message, }); });