← back to Corkwallcovering
scripts/rehost-cropped-cork.js
166 lines
#!/usr/bin/env node
/* Re-host the 23 logo-header-cropped cork images as the live product images.
* Idempotent, serial, one product at a time, verify each.
* Steve APPROVED ("all go") 2026-06-30.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const SECRETS = '/Users/macstudio3/Projects/secrets-manager/.env';
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
// --- load admin token ---
const envTxt = fs.readFileSync(SECRETS, 'utf8');
const m = envTxt.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);
if (!m) { console.error('No SHOPIFY_ADMIN_TOKEN in secrets'); process.exit(1); }
const TOKEN = m[1].trim();
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'cropped-manifest.json'), 'utf8'));
const productsPath = path.join(ROOT, 'data', 'products.json');
const products = JSON.parse(fs.readFileSync(productsPath, 'utf8'));
const base = `https://${SHOP}/admin/api/${API}`;
const headers = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// normalize a Shopify CDN url to its path (strip query string) for comparison
function normUrl(u) {
if (!u) return '';
try { const x = new URL(u); return x.origin + x.pathname; }
catch { return u.split('?')[0]; }
}
async function shopify(method, urlPath, body) {
const res = await fetch(base + urlPath, {
method, headers, body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* keep text */ }
return { ok: res.ok, status: res.status, json, text, headers: res.headers };
}
async function getProductByHandle(handle) {
// handle is unique; query products by handle
const r = await shopify('GET', `/products.json?handle=${encodeURIComponent(handle)}&fields=id,handle,title,images`);
if (!r.ok) return { error: `lookup HTTP ${r.status}: ${r.text.slice(0,200)}` };
const list = (r.json && r.json.products) || [];
const exact = list.find(p => p.handle === handle);
return { product: exact || null, raw: list };
}
async function getFullProduct(id) {
const r = await shopify('GET', `/products/${id}.json?fields=id,handle,images`);
if (!r.ok) return { error: `get HTTP ${r.status}` };
return { product: r.json.product };
}
const results = [];
(async () => {
console.log(`Processing ${manifest.length} cork SKUs...\n`);
for (const item of manifest) {
const { sku, handle, cropped_file, image_url: bannerUrl } = item;
const log = (msg) => console.log(`[${sku}] ${msg}`);
const rec = { sku, handle, status: 'PENDING', detail: '', newSrc: '' };
try {
// 1. resolve product
const lookup = await getProductByHandle(handle);
if (lookup.error) { rec.status = 'SKIP'; rec.detail = `lookup failed: ${lookup.error}`; log(rec.detail); results.push(rec); await sleep(700); continue; }
const product = lookup.product;
if (!product) { rec.status = 'SKIP'; rec.detail = 'handle did not resolve to a product'; log(rec.detail); results.push(rec); await sleep(700); continue; }
const pid = product.id;
log(`product_id=${pid} ("${product.title}") images=${product.images.length}`);
// 2. identify banner image (matches manifest original image_url by path)
const bannerNorm = normUrl(bannerUrl);
let banner = product.images.find(img => normUrl(img.src) === bannerNorm);
// idempotency: detect if a cropped image already added (filename basename of cropped_file present in some src)
const croppedBase = path.basename(cropped_file).replace(/\.[a-z]+$/i, '');
const alreadyCropped = product.images.find(img => (img.src || '').includes(croppedBase));
if (!banner && alreadyCropped) {
rec.status = 'OK';
rec.detail = 'idempotent: banner already gone, cropped image already present';
rec.newSrc = alreadyCropped.src;
log(rec.detail + ' -> ' + alreadyCropped.src);
// ensure products.json carries the cropped src
updateProductsJson(sku, handle, alreadyCropped.src.split('?')[0]);
results.push(rec); await sleep(700); continue;
}
if (!banner) { rec.status = 'SKIP'; rec.detail = `no image matching banner url (${bannerNorm}); not guessing`; log(rec.detail); results.push(rec); await sleep(700); continue; }
log(`banner image id=${banner.id} pos=${banner.position}`);
// 3. upload cropped as new image at position 1
const filePath = path.join(ROOT, 'public', cropped_file);
if (!fs.existsSync(filePath)) { rec.status = 'SKIP'; rec.detail = `cropped file missing: ${filePath}`; log(rec.detail); results.push(rec); await sleep(700); continue; }
const b64 = fs.readFileSync(filePath).toString('base64');
const filename = path.basename(cropped_file);
const up = await shopify('POST', `/products/${pid}/images.json`, {
image: { attachment: b64, filename, position: 1 }
});
if (!up.ok) { rec.status = 'SKIP'; rec.detail = `upload failed HTTP ${up.status}: ${up.text.slice(0,200)}`; log(rec.detail); results.push(rec); await sleep(1000); continue; }
const newImg = up.json.image;
log(`uploaded new image id=${newImg.id} src=${newImg.src}`);
rec.newSrc = newImg.src;
await sleep(800);
// 4. delete the old banner image
const del = await shopify('DELETE', `/products/${pid}/images/${banner.id}.json`);
if (!del.ok) { rec.status = 'PARTIAL'; rec.detail = `uploaded cropped (id ${newImg.id}) but DELETE of banner ${banner.id} failed HTTP ${del.status}`; log(rec.detail); results.push(rec); await sleep(1000); continue; }
log(`deleted banner image id=${banner.id}`);
await sleep(800);
// 5. verify
const verify = await getFullProduct(pid);
if (verify.error) { rec.status = 'PARTIAL'; rec.detail = `actions done but verify GET failed: ${verify.error}`; results.push(rec); await sleep(800); continue; }
const imgs = verify.product.images;
const bannerGone = !imgs.find(i => i.id === banner.id);
const newPresent = imgs.find(i => i.id === newImg.id);
const pos1 = imgs.find(i => i.position === 1);
const pos1IsCropped = pos1 && newPresent && pos1.id === newImg.id;
if (bannerGone && newPresent && pos1IsCropped) {
rec.status = 'OK';
rec.detail = `verified: cropped at position 1, banner gone (now ${imgs.length} imgs)`;
rec.newSrc = newPresent.src;
log(rec.detail);
} else {
rec.status = 'PARTIAL';
rec.detail = `verify mismatch: bannerGone=${bannerGone} newPresent=${!!newPresent} pos1IsCropped=${pos1IsCropped}`;
if (newPresent) rec.newSrc = newPresent.src;
log(rec.detail);
}
// 6. update products.json with new cropped CDN url (strip ?v for stability)
if (rec.newSrc) updateProductsJson(sku, handle, rec.newSrc.split('?')[0]);
} catch (e) {
rec.status = 'SKIP'; rec.detail = 'exception: ' + (e && e.message);
log(rec.detail);
}
results.push(rec);
await sleep(900); // rate-limit gap between SKUs
}
// persist products.json once at end (updates applied in-memory)
fs.writeFileSync(productsPath, JSON.stringify(products, null, 2) + '\n');
// summary
console.log('\n===== SUMMARY =====');
const ok = results.filter(r => r.status === 'OK').length;
const skip = results.filter(r => r.status === 'SKIP').length;
const partial = results.filter(r => r.status === 'PARTIAL').length;
for (const r of results) console.log(`${r.status.padEnd(8)} ${r.sku} ${r.detail}`);
console.log(`\nOK=${ok} PARTIAL=${partial} SKIP=${skip} total=${results.length}`);
fs.writeFileSync(path.join(ROOT, 'data', 'rehost-cropped-results.json'), JSON.stringify(results, null, 2));
})();
function updateProductsJson(sku, handle, newUrl) {
const p = products.find(x => x && (x.sku === sku || x.handle === handle));
if (p) { p.image_url = newUrl; }
}