Files
Art-gallery/scripts/backfill-updated-at.js
T
Danila KhodjaefandCursor f247b418d8 Add dev-prod harmonize tool for bidirectional catalog and image sync.
Schema migrates dev to prod only; catalog rows merge by updated_at and images by mtime with union merge and conflict reporting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 11:02:17 +03:00

152 lines
4.3 KiB
JavaScript

/**
* 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,
});
});