← back to Letsbegin

migrate_global_to_custom.js

208 lines

#!/usr/bin/env node
/**
 * Global → Custom Metafield Migrator
 * Copies spec data from global.* to custom.* where custom is missing
 * Easy lifts first: only fills gaps (doesn't overwrite existing custom.* values)
 *
 * Mapping:
 *   global.width → custom.width
 *   global.repeat / global.Vert-Rpt → custom.pattern_repeat
 *   global.fire_rating / global.FLAMMABILITY → custom.fire_rating
 *   global.Contents / global.Content / global.Construction → custom.material
 *   global.Collection → custom.collection_name
 *   global.Brand → custom.brand
 *   global.MATCH / global.Match → custom.match_type
 *   global.Finish / global.FINISH → custom.finish
 *   global.Cleaning → custom.care
 *   global.application → custom.application
 *   global.Substrate → custom.backing
 *   global.length / global.Length → custom.length
 *   global.packaged / global.Packaged → custom.packaging
 *   global.unit_of_measure → custom.unit_of_measure
 *   global.Weight → custom.product_weight
 *   global.Country / global.Country-of-Origin → custom.origin
 *   global.manufacturer_sku / global.vendor_name_internal → custom.manufacturer_sku (if custom is just a number)
 */

const https = require('https');
const STORE = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = (process.env.SHOPIFY_ADMIN_TOKEN || '');

function gql(body) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const req = https.request({
      hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0,300) }); } }); });
    req.on('error', reject);
    req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
    req.write(data); req.end();
  });
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

function cleanWidth(v) {
  if (!v) return null;
  const m = v.match(/([\d.]+)/);
  return m ? `${m[1]} Inches` : null;
}

function cleanFireRating(v) {
  if (!v) return null;
  let f = v.replace(/["']/g, '').trim();
  if (f.length > 100) return null; // junk data
  if (/class\s*a/i.test(f) && !f.includes('ASTM')) f = 'ASTM E-84 Class A';
  return f || null;
}

function cleanMaterial(v) {
  if (!v) return null;
  return v.replace(/\bWallpaper\b/gi, 'Wallcovering').replace(/\bWallpapers\b/gi, 'Wallcoverings').trim() || null;
}

function cleanRepeat(v) {
  if (!v || v === 'N/A' || v === '0') return null;
  const m = v.match(/([\d.]+)/);
  return m ? `${m[1]} Inches` : null;
}

// Map: globalKey → { customKey, cleaner, type }
const FIELD_MAP = {
  'width': { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
  'Width': { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
  'repeat': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
  'Vert-Rpt': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
  'Vert-Repeat': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
  'fire_rating': { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
  'FLAMMABILITY': { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
  'Contents': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
  'Content': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
  'Construction': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
  'Collection': { key: 'collection_name', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Brand': { key: 'brand', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'MATCH': { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Match': { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Finish': { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'FINISH': { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Cleaning': { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Clean-Code': { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'application': { key: 'application', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Substrate': { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'substrate': { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'length': { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Length': { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'packaged': { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Packaged': { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'unit_of_measure': { key: 'unit_of_measure', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Weight': { key: 'product_weight', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Country': { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Country-of-Origin': { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
};

async function main() {
  console.log('=== Global → Custom Metafield Migrator ===');
  console.log('Easy lifts: fill custom.* gaps from global.* data\n');

  let cursor = null, page = 1, migrated = 0, skipped = 0, total = 0, errors = 0, fieldsSet = 0;

  while (true) {
    const after = cursor ? `, after: "${cursor}"` : '';
    const r = await gql({
      query: `{ products(first: 25, query: "status:active"${after}) {
        edges { cursor node { id metafields(first: 50) { edges { node { namespace key value } } } } }
        pageInfo { hasNextPage }
      } }`
    });

    const edges = r?.data?.products?.edges || [];
    if (edges.length === 0) break;

    for (const edge of edges) {
      const p = edge.node;
      cursor = edge.cursor;
      total++;

      // Collect all metafields by namespace
      const globals = {};
      const customs = {};
      for (const m of p.metafields.edges) {
        const { namespace, key, value } = m.node;
        if (namespace === 'global' && value) globals[key] = value;
        if (namespace === 'custom' && value) customs[key] = value;
      }

      // Build list of custom.* fields to set (only where custom is missing)
      const toSet = [];
      const seen = new Set(); // avoid duplicate custom keys

      for (const [gKey, mapping] of Object.entries(FIELD_MAP)) {
        if (!globals[gKey]) continue; // no global value
        if (customs[mapping.key]) continue; // custom already has value
        if (seen.has(mapping.key)) continue; // already queued from another global key

        const cleaned = mapping.clean(globals[gKey]);
        if (!cleaned) continue;

        toSet.push({
          ownerId: p.id,
          namespace: 'custom',
          key: mapping.key,
          value: cleaned,
          type: mapping.type
        });
        seen.add(mapping.key);
      }

      // Special: manufacturer_sku — replace number-only values with real MFR
      const realMfr = globals['manufacturer_sku'] || globals['vendor_name_internal'];
      const currentMfr = customs['manufacturer_sku'] || '';
      if (realMfr && realMfr.length > 3 && /[A-Za-z]/.test(realMfr) && !/[A-Za-z]/.test(currentMfr)) {
        // Current is number-only, real has letters — replace
        const cleanMfr = realMfr.replace(/\.jpg$/i, '').replace(/\.png$/i, '').trim();
        if (cleanMfr.length > 3) {
          toSet.push({
            ownerId: p.id, namespace: 'custom', key: 'manufacturer_sku',
            value: cleanMfr, type: 'single_line_text_field'
          });
        }
      }

      if (toSet.length === 0) { skipped++; continue; }

      try {
        const result = await gql({
          query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
          variables: { m: toSet }
        });
        const errs = result?.data?.metafieldsSet?.userErrors || [];
        if (errs.length > 0) {
          errors++;
          if (errors <= 5) console.log(`  ERR: ${errs[0].message}`);
        } else {
          migrated++;
          fieldsSet += toSet.length;
        }
      } catch { errors++; }

      await sleep(400);
    }

    if (page % 10 === 0 || !r?.data?.products?.pageInfo?.hasNextPage) {
      console.log(`Page ${page}: total=${total} migrated=${migrated} (${fieldsSet} fields) skipped=${skipped} errors=${errors}`);
    }

    if (!r?.data?.products?.pageInfo?.hasNextPage) break;
    page++;
    await sleep(200);
  }

  console.log(`\n=== DONE ===`);
  console.log(`Total products: ${total}`);
  console.log(`Products with gaps filled: ${migrated} (${fieldsSet} total fields)`);
  console.log(`Already complete: ${skipped}`);
  console.log(`Errors: ${errors}`);
}

main().catch(e => { console.error('Fatal:', e); process.exit(1); });