← back to Wallco Ai
scripts/attach-shopify-bundle-files.js
228 lines
#!/usr/bin/env node
/**
* attach-shopify-bundle-files — generate the bundle's ZIP (cover + 1024 sources
* + LANCZOS-upscaled 3600 prints), upload to Shopify Files API, and attach the
* resulting URL to the product as a metafield (wallco_ai.bundle_download_url).
*
* Why: push-shopify-digital-bundle.js creates the product page but Shopify's
* core REST API doesn't have a "downloadable file" field on products. The
* standard pattern is:
* 1. Upload the ZIP via /admin/api/2026-01/files.json
* 2. Store the public file URL in a product metafield
* 3. Customer delivery via order-confirmation email (manual for now; later
* the Shopify "Digital Downloads" app can fully automate)
*
* Asset generation is ported verbatim from push-etsy-bundle.js — same PIL
* cover_grid + LANCZOS upscale + zip wrapper, so Etsy and Shopify deliver an
* identical ZIP shape.
*
* Usage:
* node scripts/attach-shopify-bundle-files.js --date 2026-05-28 --slug cactus --product 7843826270259
* node scripts/attach-shopify-bundle-files.js --date 2026-05-28 --slug cactus --product 7843826270259 --no-cleanup
* node scripts/attach-shopify-bundle-files.js --date 2026-05-28 --slug cactus --product 7843826270259 --dry
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { execSync, spawnSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
try { require('dotenv').config({ path: path.join(ROOT, '.env') }); } catch {}
function argv(name, dflt) {
const i = process.argv.indexOf('--' + name);
if (i === -1) return dflt;
if (i === process.argv.length - 1 || process.argv[i + 1].startsWith('--')) return true;
return process.argv[i + 1];
}
const date = argv('date', new Date().toISOString().slice(0, 10));
const slug = argv('slug', null);
const productId = argv('product', null);
const store = argv('store', process.env.SHOPIFY_STORE);
const token = process.env.SHOPIFY_ADMIN_TOKEN;
const dry = !!argv('dry', false);
const noCleanup = !!argv('no-cleanup', false);
if (!slug || !productId) {
console.error('usage: --slug <bundle-slug> --product <shopify-product-id> [--date YYYY-MM-DD] [--dry] [--no-cleanup]');
process.exit(2);
}
if (!dry && (!token || !store)) {
console.error('missing SHOPIFY_ADMIN_TOKEN or SHOPIFY_STORE — use --dry to skip the API call');
process.exit(3);
}
const bundleDir = path.join(ROOT, 'data', 'etsy-exports', date, 'bundles', slug);
const listingPath = path.join(bundleDir, 'listing.json');
if (!fs.existsSync(listingPath)) { console.error(`listing not found: ${listingPath}`); process.exit(4); }
const listing = JSON.parse(fs.readFileSync(listingPath, 'utf8'));
const designIds = (listing.design_ids || []).filter(Number.isFinite);
console.log(`bundle: ${slug} · ${designIds.length} designs · product ${productId}`);
// Resolve local_path for each design (PG dw_unified)
function pgArray() {
const rows = execSync(
`psql dw_unified -At -F'\x1f' -c "SELECT id, COALESCE(local_path,'') FROM all_designs WHERE id IN (${designIds.join(',')});"`,
{ encoding: 'utf8' }
).trim();
if (!rows) return [];
return rows.split('\n').filter(Boolean).map(r => { const [id, lp] = r.split('\x1f'); return { id: +id, local_path: lp }; });
}
const designs = pgArray().filter(d => d.local_path && fs.existsSync(d.local_path));
if (designs.length !== designIds.length) {
console.warn(`only ${designs.length}/${designIds.length} source files present on disk — proceeding with what's available`);
}
if (!designs.length) { console.error('no source files exist; aborting'); process.exit(5); }
// ── ZIP build (ported from push-etsy-bundle.js asset gen) ─────────────────
function buildZip() {
const filesDir = path.join(bundleDir, 'files');
fs.mkdirSync(filesDir, { recursive: true });
const cols = Math.min(4, Math.ceil(Math.sqrt(designs.length)));
const upscalePx = 3600;
const py = `
import os, json
from PIL import Image
items = ${JSON.stringify(designs.map(d => ({ id: d.id, src: d.local_path })))}
cols = ${cols}
upscale_px = ${upscalePx}
cw = 600
rows = (len(items) + cols - 1) // cols
canvas = Image.new('RGB', (cw * cols, cw * rows), '#0e0f10')
for i, it in enumerate(items):
src = Image.open(it['src']).convert('RGB').resize((cw, cw), Image.LANCZOS)
canvas.paste(src, ((i % cols) * cw, (i // cols) * cw))
canvas.save(${JSON.stringify(path.join(bundleDir, 'cover_grid.png'))}, optimize=True)
files_dir = ${JSON.stringify(filesDir)}
for it in items:
src = Image.open(it['src']).convert('RGB')
src.save(os.path.join(files_dir, f"{it['id']}_source_1024.png"), optimize=True)
src.resize((upscale_px, upscale_px), Image.LANCZOS).save(
os.path.join(files_dir, f"{it['id']}_print_3600.png"), optimize=True
)
print(json.dumps({"cover": ${JSON.stringify(path.join(bundleDir, 'cover_grid.png'))}, "files_dir": files_dir, "count": len(items), "upscale_px": upscale_px}))
`;
console.log(`building ${designs.length}-design ZIP (cover + 1024 source + 3600 LANCZOS upscale)…`);
const r = spawnSync('python3', ['-c', py], { encoding: 'utf8', timeout: 600000, maxBuffer: 16 * 1024 * 1024 });
if (r.status !== 0) { console.error('PIL stderr:', r.stderr); throw new Error('asset gen failed'); }
const readme = `${listing.title || slug}\n\nIncluded in this bundle:\n- cover_grid.png — preview of all ${designs.length} pattern(s)\n- files/<id>_source_1024.png — original 1024×1024 seamless tile\n- files/<id>_print_3600.png — LANCZOS-upscaled 3600×3600 (24" @ 150 DPI for print)\n\nEach tile is seamless — tile-and-repeat to cover any wall.\n\nCommercial use included (no attribution required). Mass redistribution of the patterns themselves is NOT licensed.\n\n— wallco.ai by Designer Wallcoverings\n`;
fs.writeFileSync(path.join(bundleDir, 'README.txt'), readme);
const zipPath = path.join(bundleDir, `${slug}.zip`);
execSync(`cd ${JSON.stringify(bundleDir)} && rm -f ${JSON.stringify(slug + '.zip')} && zip -q -r ${JSON.stringify(slug + '.zip')} files cover_grid.png README.txt`, { stdio: 'inherit' });
const sz = fs.statSync(zipPath).size;
console.log(`✓ ZIP built: ${path.relative(ROOT, zipPath)} (${(sz/1024/1024).toFixed(2)} MB)`);
return { zipPath, zipBytes: sz };
}
// ── Shopify Files API upload via stagedUploadsCreate + multipart PUT ──────
async function shopifyUploadZip(zipPath) {
const url = `https://${store}/admin/api/2026-01/graphql.json`;
const filename = path.basename(zipPath);
const sz = fs.statSync(zipPath).size;
// Step 1: stagedUploadsCreate to get a presigned target
const stagedQuery = `mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets { url resourceUrl parameters { name value } }
userErrors { field message }
}
}`;
const stagedVars = { input: [{
filename, mimeType: 'application/zip', httpMethod: 'POST', resource: 'FILE', fileSize: String(sz),
}]};
console.log(`POST graphql stagedUploadsCreate for ${filename} (${sz} bytes)`);
const r1 = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token },
body: JSON.stringify({ query: stagedQuery, variables: stagedVars }),
});
const j1 = await r1.json();
if (j1.errors || (j1.data && j1.data.stagedUploadsCreate && j1.data.stagedUploadsCreate.userErrors.length)) {
console.error('stagedUploadsCreate failed:', JSON.stringify(j1).slice(0, 800)); process.exit(6);
}
const target = j1.data.stagedUploadsCreate.stagedTargets[0];
const stageUrl = target.url;
const resourceUrl = target.resourceUrl;
// Step 2: POST the zip to the staged URL with the returned form parameters
const FormData = require('form-data');
const form = new FormData();
for (const p of target.parameters) form.append(p.name, p.value);
form.append('file', fs.createReadStream(zipPath), { filename, contentType: 'application/zip' });
console.log(`POST staged upload → ${stageUrl}`);
const r2 = await fetch(stageUrl, { method: 'POST', body: form, headers: form.getHeaders() });
if (!r2.ok && r2.status !== 201 && r2.status !== 204) {
const txt = await r2.text();
console.error(`stage upload ${r2.status}:`, txt.slice(0, 600)); process.exit(7);
}
// Step 3: fileCreate to register the uploaded blob as a Shopify File
const fcQuery = `mutation fileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files { id alt fileStatus ... on GenericFile { url } }
userErrors { field message }
}
}`;
const fcVars = { files: [{ originalSource: resourceUrl, alt: `${slug} bundle download` }] };
console.log('POST graphql fileCreate');
const r3 = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token },
body: JSON.stringify({ query: fcQuery, variables: fcVars }),
});
const j3 = await r3.json();
if (j3.errors || (j3.data && j3.data.fileCreate && j3.data.fileCreate.userErrors.length)) {
console.error('fileCreate failed:', JSON.stringify(j3).slice(0, 800)); process.exit(8);
}
const file = j3.data.fileCreate.files[0];
console.log(`✓ Shopify File created: ${file.id} (status=${file.fileStatus})`);
// file.url may be empty until processing completes; resourceUrl is the canonical reference.
return { fileId: file.id, fileUrl: file.url || null, resourceUrl };
}
// ── Attach a metafield to the product with the download URL ───────────────
async function attachMetafield(productGid, fileRef) {
const url = `https://${store}/admin/api/2026-01/graphql.json`;
const q = `mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id namespace key value type }
userErrors { field message }
}
}`;
const v = { metafields: [
{ ownerId: productGid, namespace: 'wallco_ai', key: 'bundle_download_file_id', value: fileRef.fileId, type: 'single_line_text_field' },
...(fileRef.fileUrl ? [{ ownerId: productGid, namespace: 'wallco_ai', key: 'bundle_download_url', value: fileRef.fileUrl, type: 'url' }] : []),
]};
const r = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token },
body: JSON.stringify({ query: q, variables: v }),
});
const j = await r.json();
if (j.errors || (j.data && j.data.metafieldsSet && j.data.metafieldsSet.userErrors.length)) {
console.error('metafieldsSet failed:', JSON.stringify(j).slice(0, 600)); process.exit(9);
}
console.log(`✓ metafields set on ${productGid}`);
}
// ── main ─────────────────────────────────────────────────────────────────
(async () => {
const { zipPath, zipBytes } = buildZip();
if (dry) { console.log('--- DRY: would upload to Shopify Files + set product metafield. Stopping. ---'); return; }
const file = await shopifyUploadZip(zipPath);
const productGid = `gid://shopify/Product/${productId}`;
await attachMetafield(productGid, file);
// Update the bucket row with notes so we know the ZIP is attached
try {
execSync(`psql dw_unified -q -c "UPDATE wallco_etsy_bucket SET notes='shopify-draft+zip:${file.fileId.replace(/'/g,'')}' WHERE design_id IN (${designIds.join(',')});"`, { stdio: 'inherit' });
} catch (e) { console.warn('bucket note update failed (non-fatal):', e.message.slice(0, 120)); }
if (!noCleanup) {
// Clean up local assets — the canonical copy is now on Shopify
try {
execSync(`rm -rf ${JSON.stringify(path.join(bundleDir, 'files'))} ${JSON.stringify(zipPath)} ${JSON.stringify(path.join(bundleDir, 'cover_grid.png'))}`, { stdio: 'ignore' });
console.log('✓ local assets cleaned (canonical copy now on Shopify)');
} catch {}
}
console.log(`done. product ${productId} now has bundle_download_file_id=${file.fileId}`);
})();