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