323 lines
9.6 KiB
JavaScript
323 lines
9.6 KiB
JavaScript
/**
|
|
* 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,
|
|
});
|
|
});
|