Sync checkup flags with image harmonize; tighten gallery collision and exit-to-timeline.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
41d2d5d844
commit
67594ea4d3
@@ -143,7 +143,7 @@ Run `npm run dev:migrate` against prod DB after first deploy with auth vars set
|
||||
| `npm run devtoprod:db:restore -- --file <path>` | Restore into **prod** (requires confirmation) |
|
||||
| `npm run harmonize` | Bidirectional catalog DB + image merge by `updated_at` / file mtime — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
||||
| `npm run harmonize:schema` | Apply dev migrations to prod schema only (dev → prod) |
|
||||
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only (`harmonize:images` also regenerates painting + portrait thumbs on both sides) |
|
||||
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only (`harmonize:images` also merges artists/paintings checkup flags + image paths, then regenerates thumbs on both sides) |
|
||||
| `npm run dev:backfill-updated-at` | Backfill catalog `updated_at` from image mtimes (dev) |
|
||||
|
||||
**One-time split (recommended):** pgAdmin on dev PC → open [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) → run each STEP on database `postgres`, then verify on `gallery_dev`.
|
||||
|
||||
@@ -31,7 +31,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
|
||||
| **Development** | `./data/images/` in repo | Working copy on dev PC |
|
||||
| **Production** | `/mnt/BasePool/Applications/Gallery/data/images` on TrueNAS | SMB `\\192.168.10.122\Gallery\data\images` |
|
||||
|
||||
**One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh all images on dev from prod:** `npm run prodto:dev:images`. **One artist only:** map the SMB share, then `robocopy` that artist’s `ArtistName*` files under `paintings/` and `paintings/thumbs/` (see [FAC.md](FAC.md#dev--prod-image-sync-smb)). **Bidirectional merge** (newer file wins, then rebuild painting + portrait thumbs on both sides): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
|
||||
**One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh all images on dev from prod:** `npm run prodto:dev:images`. **One artist only:** map the SMB share, then `robocopy` that artist’s `ArtistName*` files under `paintings/` and `paintings/thumbs/` (see [FAC.md](FAC.md#dev--prod-image-sync-smb)). **Bidirectional merge** (newer file wins, then merge artists/paintings checkup flags + image paths, then rebuild painting + portrait thumbs on both sides): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
|
||||
|
||||
## Scripts overview
|
||||
|
||||
@@ -49,7 +49,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
|
||||
| `fetch-missing-images.js` | `npm run dev:fetch-images` | Downloads files for paintings missing on disk |
|
||||
| `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API |
|
||||
| `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run devtoprod:images` / `npm run prodto:dev:images` | Robocopy via SMB `\\192.168.10.122\Gallery` |
|
||||
| `harmonize-db.js` / `harmonize-images.js` | `npm run harmonize:db` / `harmonize:images` | Bidirectional merge by `updated_at` / file mtime; images step then rebuilds thumbs on both sides |
|
||||
| `harmonize-db.js` / `harmonize-images.js` | `npm run harmonize:db` / `harmonize:images` | Bidirectional merge by `updated_at` / file mtime; images step also merges artists/paintings checkup flags + paths, then rebuilds thumbs on both sides |
|
||||
| `harmonize.ps1` | `npm run harmonize` | Orchestrator: backups, optional schema, DB + image merge |
|
||||
| `regenerate-thumbnails.js` | `npm run dev:regenerate-thumbnails` | Rebuild painting thumbs from full images via `sharp` |
|
||||
| `regenerate-portrait-thumbs.js` | `npm run dev:regenerate-portrait-thumbs` | Rebuild timeline portrait thumbs (~256px) and set `portrait_thumb_path` |
|
||||
|
||||
@@ -341,7 +341,7 @@ For **incremental** dev ↔ prod merge (both sides edited), use [harmonize-dev-p
|
||||
| `npm run devtoprod:db:restore` | Dev PC PowerShell | Restore into prod |
|
||||
| `npm run harmonize` | Dev PC PowerShell | Bidirectional catalog + image merge — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
||||
| `npm run harmonize:db` | Dev PC PowerShell | DB merge only |
|
||||
| `npm run harmonize:images` | Dev PC PowerShell | Image merge + regenerate thumbs on both sides |
|
||||
| `npm run harmonize:images` | Dev PC PowerShell | Image merge + artists/paintings checkup/path sync + regenerate thumbs on both sides |
|
||||
|
||||
## Image sync
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ npm run harmonize:dry-run
|
||||
| `backupProd` | `prod:db:backup` | Safety snapshot |
|
||||
| `schema` | `harmonize:schema` | Apply dev migrations to prod (when `schemaChanged: true`) |
|
||||
| `db` | `harmonize:db` | Row-level catalog merge |
|
||||
| `images` | `harmonize:images` | Bidirectional file merge, then regenerate painting + portrait thumbs on **dev and prod** |
|
||||
| `images` | `harmonize:images` | Bidirectional file merge, then merge **artists/paintings** (checkup flags + image paths), then regenerate thumbs on **dev and prod** |
|
||||
| `verify` | curl `/api/bounds` | Optional smoke check |
|
||||
|
||||
Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_images_*.json` (gitignored).
|
||||
@@ -98,9 +98,10 @@ Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_image
|
||||
| `npm run harmonize:db` | Merge catalog rows by `updated_at` |
|
||||
| `npm run harmonize:db -- --dry-run` | Preview DB changes |
|
||||
| `npm run harmonize:db -- --prefer=dev` | On equal `updated_at`, dev wins |
|
||||
| `npm run harmonize:images` | Merge image files by mtime, then regenerate painting + portrait thumbnails on both sides |
|
||||
| `npm run harmonize:images -- --skip-thumbnails` | File merge only (no thumb rebuild) |
|
||||
| `npm run harmonize:images -- --dry-run` | Preview file copies only (skips thumbnails) |
|
||||
| `npm run harmonize:images` | Merge image files by mtime, merge artists/paintings checkup flags + image paths, then regenerate thumbs on both sides |
|
||||
| `npm run harmonize:images -- --skip-thumbnails` | File + checkup/path DB merge only (no thumb rebuild) |
|
||||
| `npm run harmonize:images -- --skip-db` | File merge (+ thumbs) without artists/paintings checkup sync |
|
||||
| `npm run harmonize:images -- --dry-run` | Preview file copies and catalog merge (skips thumbnails) |
|
||||
| `npm run dev:migrate:sync-timestamps` | Apply `updated_at` migration on dev only |
|
||||
| `npm run dev:backfill-updated-at` | Backfill dev `updated_at` from image mtimes |
|
||||
| `npm run harmonize:backfill-updated-at` | Same backfill on prod |
|
||||
|
||||
@@ -419,3 +419,9 @@
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.exit-nav-footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(201, 169, 110, 0.25);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,8 @@ const WALL_PADDING = 1.4;
|
||||
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
|
||||
const MAX_WALL_ROWS = 1;
|
||||
const DOOR_WIDTH = 2.4;
|
||||
/** Minimum horizontal distance from walls, corners, door planes, and painting faces. */
|
||||
const PLAYER_CLEARANCE = 0.5;
|
||||
const DOOR_HEIGHT = 2.5;
|
||||
/** Museum exit — dimensions derived from door opening. */
|
||||
const EXIT_JAMB = 0.13;
|
||||
@@ -176,6 +178,73 @@ interface HallLayout {
|
||||
segments: WallSegment[];
|
||||
}
|
||||
|
||||
interface XZBounds {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minZ: number;
|
||||
maxZ: number;
|
||||
}
|
||||
|
||||
/** Inner wall face half-extents (room center → inside of wall box). */
|
||||
function innerWallHalfExtents(width: number, depth: number): { halfW: number; halfD: number } {
|
||||
return {
|
||||
halfW: width / 2 - WALL_THICKNESS / 2,
|
||||
halfD: depth / 2 - WALL_THICKNESS / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep-out box in front of a hanging painting (XZ), including player clearance. */
|
||||
function paintingKeepOutBounds(slot: FrameSlot): XZBounds {
|
||||
const [px, , pz] = slot.position;
|
||||
const halfAlong = slot.maxW / 2 + FRAME_MAT_BORDER + FRAME_RAIL + 0.04;
|
||||
const intoRoom = FRAME_DEPTH + FRAME_FACE_Z + PLAYER_CLEARANCE;
|
||||
if (slot.side === 'left') {
|
||||
return { minX: px - 0.02, maxX: px + intoRoom, minZ: pz - halfAlong, maxZ: pz + halfAlong };
|
||||
}
|
||||
if (slot.side === 'right') {
|
||||
return { minX: px - intoRoom, maxX: px + 0.02, minZ: pz - halfAlong, maxZ: pz + halfAlong };
|
||||
}
|
||||
// back wall — faces into the room (+Z)
|
||||
return { minX: px - halfAlong, maxX: px + halfAlong, minZ: pz - 0.02, maxZ: pz + intoRoom };
|
||||
}
|
||||
|
||||
/** Door jamb keep-outs at an opening in the front (+Z) or back (−Z) wall. */
|
||||
function doorJambKeepOuts(halfD: number, atFront: boolean): XZBounds[] {
|
||||
const jambZ = atFront ? halfD : -halfD;
|
||||
const along = PLAYER_CLEARANCE;
|
||||
const intoRoom = PLAYER_CLEARANCE;
|
||||
const leftX = -DOOR_WIDTH / 2;
|
||||
const rightX = DOOR_WIDTH / 2;
|
||||
if (atFront) {
|
||||
return [
|
||||
{ minX: leftX - along, maxX: leftX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 },
|
||||
{ minX: rightX - along, maxX: rightX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ minX: leftX - along, maxX: leftX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom },
|
||||
{ minX: rightX - along, maxX: rightX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom },
|
||||
];
|
||||
}
|
||||
|
||||
function pointInBounds(x: number, z: number, b: XZBounds): boolean {
|
||||
return x > b.minX && x < b.maxX && z > b.minZ && z < b.maxZ;
|
||||
}
|
||||
|
||||
/** Push a point out of an AABB via the shortest axis (XZ). */
|
||||
function pushOutOfBounds(pos: { x: number; z: number }, b: XZBounds): void {
|
||||
if (!pointInBounds(pos.x, pos.z, b)) return;
|
||||
const dxMin = pos.x - b.minX;
|
||||
const dxMax = b.maxX - pos.x;
|
||||
const dzMin = pos.z - b.minZ;
|
||||
const dzMax = b.maxZ - pos.z;
|
||||
const m = Math.min(dxMin, dxMax, dzMin, dzMax);
|
||||
if (m === dxMin) pos.x = b.minX;
|
||||
else if (m === dxMax) pos.x = b.maxX;
|
||||
else if (m === dzMin) pos.z = b.minZ;
|
||||
else pos.z = b.maxZ;
|
||||
}
|
||||
|
||||
function paintingIsReviewed(painting: Painting): boolean {
|
||||
return !!painting.checkup_checked;
|
||||
}
|
||||
@@ -1749,7 +1818,7 @@ function MovementHallNavPanel({
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
|
||||
Exit to Timeline
|
||||
Back to Timeline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1762,11 +1831,13 @@ function NavigationPanel({
|
||||
navigation,
|
||||
loading,
|
||||
onSelect,
|
||||
onExitTimeline,
|
||||
onClose,
|
||||
}: {
|
||||
navigation: ArtistNavigation | null;
|
||||
loading: boolean;
|
||||
onSelect: (artistId: number) => void;
|
||||
onExitTimeline: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => (
|
||||
@@ -1822,6 +1893,11 @@ function NavigationPanel({
|
||||
'No documented successors via painting influences.'
|
||||
)}
|
||||
</div>
|
||||
<div className="exit-nav-footer">
|
||||
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
|
||||
Back to Timeline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2017,11 +2093,62 @@ export default function VirtualGallery(props: Props) {
|
||||
|
||||
const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
|
||||
|
||||
const halfW = layout.width / 2 - 0.55;
|
||||
const halfD = layout.depth / 2 - 0.35;
|
||||
const exitZ = layout.depth / 2 - 0.55;
|
||||
const { halfW: wallInnerHalfW, halfD: wallInnerHalfD } = useMemo(
|
||||
() => innerWallHalfExtents(layout.width, layout.depth),
|
||||
[layout.width, layout.depth]
|
||||
);
|
||||
/** Playable half-extents: 0.5 m clear of inner wall faces (corners included). */
|
||||
const playHalfW = wallInnerHalfW - PLAYER_CLEARANCE;
|
||||
const playHalfD = wallInnerHalfD - PLAYER_CLEARANCE;
|
||||
const exitZ = playHalfD;
|
||||
const fogFar = Math.max(55, layout.depth + 42);
|
||||
|
||||
const collisionObstacles = useMemo(() => {
|
||||
const boxes: XZBounds[] = [];
|
||||
for (const seg of layout.segments) {
|
||||
for (const slot of seg.slots) boxes.push(paintingKeepOutBounds(slot));
|
||||
}
|
||||
// Door jambs — keep 0.5 m from the opening posts; opening corridor stays walkable.
|
||||
boxes.push(...doorJambKeepOuts(wallInnerHalfD, true));
|
||||
if (isWingedHall) boxes.push(...doorJambKeepOuts(wallInnerHalfD, false));
|
||||
return boxes;
|
||||
}, [layout.segments, wallInnerHalfD, isWingedHall]);
|
||||
|
||||
const resolvePlayerPosition = useCallback(
|
||||
(pos: { x: number; z: number }, allowFrontDoorApproach: boolean) => {
|
||||
const clampToWalls = () => {
|
||||
pos.x = Math.max(-playHalfW, Math.min(playHalfW, pos.x));
|
||||
const inDoorBand = Math.abs(pos.x) < DOOR_WIDTH / 2 - PLAYER_CLEARANCE * 0.35;
|
||||
if (isWingedHall) {
|
||||
let minZ = -playHalfD;
|
||||
let maxZ = playHalfD;
|
||||
if (inDoorBand) {
|
||||
minZ = -wallInnerHalfD + 0.08;
|
||||
if (hasNextHall || allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08;
|
||||
}
|
||||
pos.z = Math.max(minZ, Math.min(maxZ, pos.z));
|
||||
} else {
|
||||
let maxZ = exitZ;
|
||||
if (inDoorBand && allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08;
|
||||
pos.z = Math.max(-playHalfD, Math.min(maxZ, pos.z));
|
||||
}
|
||||
};
|
||||
|
||||
clampToWalls();
|
||||
for (const box of collisionObstacles) pushOutOfBounds(pos, box);
|
||||
clampToWalls();
|
||||
},
|
||||
[
|
||||
playHalfW,
|
||||
playHalfD,
|
||||
exitZ,
|
||||
isWingedHall,
|
||||
hasNextHall,
|
||||
wallInnerHalfD,
|
||||
collisionObstacles,
|
||||
]
|
||||
);
|
||||
|
||||
const initialPos = useMemo(
|
||||
() => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
|
||||
[layout.depth]
|
||||
@@ -2091,8 +2218,24 @@ export default function VirtualGallery(props: Props) {
|
||||
}
|
||||
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
|
||||
|
||||
const backExitZ = isWingedHall ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
|
||||
const frontPassageZ = layout.depth / 2 - 0.55;
|
||||
const backExitZ = isWingedHall ? -playHalfD : exitZ;
|
||||
const frontPassageZ = playHalfD;
|
||||
|
||||
const updateProximityFlags = useCallback(
|
||||
(pos: { x: number; z: number }) => {
|
||||
if (isWingedHall) {
|
||||
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||
const atFrontPassage =
|
||||
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||
setNearExit(atBackExit);
|
||||
setNearPassage(!!atFrontPassage);
|
||||
} else {
|
||||
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||
setNearExit(atExit);
|
||||
}
|
||||
},
|
||||
[isWingedHall, backExitZ, frontPassageZ, hasNextHall, exitZ]
|
||||
);
|
||||
|
||||
const moveCamera = useCallback(
|
||||
(forward: number, strafe: number, rotY: number) => {
|
||||
@@ -2100,43 +2243,49 @@ export default function VirtualGallery(props: Props) {
|
||||
const target = camTargetRef.current.clone();
|
||||
const angle = Math.atan2(target.x - pos.x, target.z - pos.z);
|
||||
|
||||
// Turn in place: never move; only change look direction.
|
||||
if (rotY !== 0) {
|
||||
const newAngle = angle + rotY;
|
||||
const dist = pos.distanceTo(target);
|
||||
const dist = Math.max(0.5, pos.distanceTo(target));
|
||||
target.x = pos.x + Math.sin(newAngle) * dist;
|
||||
target.z = pos.z + Math.cos(newAngle) * dist;
|
||||
} else {
|
||||
const newAngle = angle;
|
||||
pos.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
|
||||
pos.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
|
||||
target.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
|
||||
target.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
|
||||
}
|
||||
|
||||
pos.x = Math.max(-halfW, Math.min(halfW, pos.x));
|
||||
target.x = Math.max(-halfW, Math.min(halfW, target.x));
|
||||
|
||||
if (isWingedHall) {
|
||||
pos.z = Math.max(-halfD, Math.min(halfD, pos.z));
|
||||
target.z = Math.max(-halfD, Math.min(halfD, target.z));
|
||||
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||
const atFrontPassage =
|
||||
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||
setNearExit(atBackExit);
|
||||
setNearPassage(!!atFrontPassage);
|
||||
} else {
|
||||
pos.z = Math.max(-halfD, Math.min(exitZ, pos.z));
|
||||
target.z = Math.max(-halfD, Math.min(exitZ, target.z));
|
||||
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||
setNearExit(atExit);
|
||||
}
|
||||
|
||||
levelHorizontalView(pos, target);
|
||||
updateProximityFlags(pos);
|
||||
setCamPos(pos);
|
||||
setCamTarget(target);
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = Math.sin(angle) * forward + Math.sin(angle + Math.PI / 2) * strafe;
|
||||
const dz = Math.cos(angle) * forward + Math.cos(angle + Math.PI / 2) * strafe;
|
||||
if (dx === 0 && dz === 0) {
|
||||
updateProximityFlags(pos);
|
||||
return;
|
||||
}
|
||||
|
||||
const proposed = { x: pos.x + dx, z: pos.z + dz };
|
||||
const resolved = { x: proposed.x, z: proposed.z };
|
||||
resolvePlayerPosition(resolved, true);
|
||||
|
||||
// Hit wall/painting/door jamb: cancel the whole step — no slide, no view change.
|
||||
if (
|
||||
Math.abs(resolved.x - proposed.x) > 1e-4 ||
|
||||
Math.abs(resolved.z - proposed.z) > 1e-4
|
||||
) {
|
||||
updateProximityFlags(pos);
|
||||
return;
|
||||
}
|
||||
|
||||
pos.x = proposed.x;
|
||||
pos.z = proposed.z;
|
||||
target.x += dx;
|
||||
target.z += dz;
|
||||
levelHorizontalView(pos, target);
|
||||
updateProximityFlags(pos);
|
||||
setCamPos(pos);
|
||||
setCamTarget(target);
|
||||
},
|
||||
[halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall]
|
||||
[resolvePlayerPosition, updateProximityFlags]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2362,6 +2511,7 @@ export default function VirtualGallery(props: Props) {
|
||||
navigation={navigation}
|
||||
loading={navLoading}
|
||||
onSelect={handleNavigate}
|
||||
onExitTimeline={onBack}
|
||||
onClose={() => setShowExitNav(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -2407,7 +2557,7 @@ export default function VirtualGallery(props: Props) {
|
||||
) : (
|
||||
<>
|
||||
<li>Golden lamps mark works linked in the influence graph</li>
|
||||
<li>Click the exit door or <kbd>E</kbd> to visit related artists</li>
|
||||
<li>Click the exit door or <kbd>E</kbd> for related artists or back to the timeline</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* 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');
|
||||
@@ -17,6 +19,9 @@ 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',
|
||||
@@ -24,10 +29,13 @@ const THUMB_SCRIPTS = [
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -117,6 +125,35 @@ function regenerateThumbnails({ label, imageDir, database }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [
|
||||
@@ -146,7 +183,7 @@ async function main() {
|
||||
|
||||
if (!dryRun && process.env.CONFIRM_PROD !== '1') {
|
||||
await confirmProdAction(
|
||||
'Harmonize will copy image files between dev and prod, then regenerate painting and portrait thumbnails on both sides (prod files and gallery_prod paths may be updated).',
|
||||
'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).',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,7 +242,23 @@ async function main() {
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
const reportPath = path.join(reportDir, `harmonize_images_${timestampSlug()}.json`);
|
||||
|
||||
const thumbDetails = [];
|
||||
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';
|
||||
@@ -215,24 +268,26 @@ async function main() {
|
||||
imageDir: devDir,
|
||||
database: devDb,
|
||||
});
|
||||
thumbDetails.push(`Dev thumbs regenerated (${devDb} @ ${devDir})`);
|
||||
extraDetails.push(`Dev thumbs regenerated (${devDb} @ ${devDir})`);
|
||||
|
||||
regenerateThumbnails({
|
||||
label: 'prod',
|
||||
imageDir: prodDir,
|
||||
database: PROD_DB_NAME,
|
||||
});
|
||||
thumbDetails.push(`Prod thumbs regenerated (${PROD_DB_NAME} @ ${prodDir})`);
|
||||
extraDetails.push(`Prod thumbs regenerated (${PROD_DB_NAME} @ ${prodDir})`);
|
||||
} else if (options.skipThumbnails) {
|
||||
thumbDetails.push('Thumbnails skipped (--skip-thumbnails)');
|
||||
extraDetails.push('Thumbnails skipped (--skip-thumbnails)');
|
||||
} else {
|
||||
thumbDetails.push('Thumbnails skipped (dry-run)');
|
||||
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: {
|
||||
@@ -240,7 +295,7 @@ async function main() {
|
||||
prodToDev: stats.prodToDev,
|
||||
skipped: stats.skipped,
|
||||
},
|
||||
thumbnails: thumbDetails,
|
||||
steps: extraDetails,
|
||||
actions: options.verbose ? stats.actions : stats.actions.slice(0, 500),
|
||||
}, null, 2));
|
||||
|
||||
@@ -253,7 +308,7 @@ async function main() {
|
||||
`Dev: ${devDir}`,
|
||||
`Prod: ${prodDir}`,
|
||||
`Report: ${reportPath}`,
|
||||
...thumbDetails,
|
||||
...extraDetails,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user