/** * Bidirectional image harmonize (dev <-> prod) by file mtime. * * Usage: * npm run harmonize:images * npm run harmonize:images -- --dry-run */ const fs = require('fs'); const path = require('path'); const { rootDir, confirmProdAction } = 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; function parseArgs(argv) { const configArg = argv.find((a) => a.startsWith('--config=')); return { dryRun: argv.includes('--dry-run'), verbose: argv.includes('--verbose'), 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); } 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 (prod files may be overwritten).', ); } 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`); fs.writeFileSync(reportPath, JSON.stringify({ generatedAt: new Date().toISOString(), dryRun, devDir, prodDir, stats: { devToProd: stats.devToProd, prodToDev: stats.prodToDev, skipped: stats.skipped, }, 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}`], }); } main().catch((err) => { printCliResult({ script: 'harmonize-images', ok: false, summary: err.message, }); });