/** * Backfill catalog updated_at from linked image file mtimes where possible. * * Dev: npm run dev:backfill-updated-at * Prod: npm run harmonize:backfill-updated-at */ const fs = require('fs'); const path = require('path'); const pg = require('pg'); const { assertDevDatabase, assertProdDatabase, loadDevPgConfig, loadProdPgConfig, rootDir, } = require('./db-env'); const { printCliResult } = require('./lib/cli-result'); const { Client } = pg; function parseArgs(argv) { return { prod: argv.includes('--prod') }; } function resolveImageDir(prod) { if (prod) { const envPath = path.join(rootDir, 'infra', 'docker', '.env.prod'); if (fs.existsSync(envPath)) { for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) { const trimmed = line.trim(); if (trimmed.startsWith('IMAGE_DIR=')) { const val = trimmed.slice('IMAGE_DIR='.length).trim().replace(/^["']|["']$/g, ''); if (val) return path.resolve(val); } } } return path.resolve('\\\\192.168.10.122\\Gallery\\data\\images'); } require('dotenv').config({ path: path.join(rootDir, '.env') }); return path.resolve(process.env.IMAGE_DIR || path.join(rootDir, 'data', 'images')); } function fileMtimeMs(imageDir, relPath) { if (!relPath || typeof relPath !== 'string') return null; const abs = path.join(imageDir, relPath.replace(/^\//, '')); try { if (!fs.existsSync(abs)) return null; return fs.statSync(abs).mtimeMs; } catch { return null; } } function maxMtime(imageDir, paths) { let max = null; for (const rel of paths) { const ms = fileMtimeMs(imageDir, rel); if (ms != null && (max == null || ms > max)) max = ms; } return max; } async function main() { const { prod } = parseArgs(process.argv.slice(2)); const config = prod ? loadProdPgConfig() : loadDevPgConfig(); if (prod) assertProdDatabase(config.database); else assertDevDatabase(config.database); const imageDir = resolveImageDir(prod); const client = new Client(config); await client.connect(); const fallback = new Date(); let updated = 0; const { rows: artists } = await client.query( 'SELECT id, portrait_path, portrait_thumb_path FROM artists', ); for (const row of artists) { const ms = maxMtime(imageDir, [row.portrait_path, row.portrait_thumb_path]); const ts = ms != null ? new Date(ms) : fallback; await client.query('UPDATE artists SET updated_at = $1 WHERE id = $2', [ts, row.id]); updated += 1; } const { rows: paintings } = await client.query( 'SELECT id, image_path, thumbnail_path FROM paintings', ); for (const row of paintings) { const ms = maxMtime(imageDir, [row.image_path, row.thumbnail_path]); const ts = ms != null ? new Date(ms) : fallback; await client.query('UPDATE paintings SET updated_at = $1 WHERE id = $2', [ts, row.id]); updated += 1; } const childUpdates = [ ['artist_periods', 'artist_id', 'artists'], ['painting_influences', 'painting_id', 'paintings'], ['painting_influence_sources', 'painting_id', 'paintings'], ['painting_annotations', 'painting_id', 'paintings'], ]; for (const [child, fk, parent] of childUpdates) { const res = await client.query( `UPDATE ${child} c SET updated_at = GREATEST(c.updated_at, p.updated_at) FROM ${parent} p WHERE c.${fk} = p.id`, ); updated += res.rowCount || 0; } for (const table of ['historical_eras', 'art_movements']) { const res = await client.query( `UPDATE ${table} SET updated_at = $1 WHERE updated_at IS NOT NULL`, [fallback], ); updated += res.rowCount || 0; } await client.query( `UPDATE art_movements m SET updated_at = GREATEST(m.updated_at, e.updated_at) FROM historical_eras e WHERE m.era_id = e.id`, ); await client.query( `UPDATE artists a SET updated_at = GREATEST(a.updated_at, m.updated_at) FROM art_movements m WHERE a.movement_id = m.id`, ); await client.end(); printCliResult({ script: 'backfill-updated-at', ok: true, summary: `Backfilled updated_at on ${config.database} (${updated} row touches).`, details: [`Image dir: ${imageDir}`], }); } main().catch((err) => { printCliResult({ script: 'backfill-updated-at', ok: false, summary: err.message, }); });