← 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; }
}