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