[object Object]

← back to Sister Parish Onboarding

sp: private_label.js — dedupe + renumber to DWSH-10001 series (Saybrook House)

386a36063f47941a56dbaa79e7337ff83d965633 · 2026-05-18 09:50:15 -0700 · SteveStudio2

Files touched

Diff

commit 386a36063f47941a56dbaa79e7337ff83d965633
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 18 09:50:15 2026 -0700

    sp: private_label.js — dedupe + renumber to DWSH-10001 series (Saybrook House)
---
 scripts/append_title.js        |  61 +++++++++++++++++++
 scripts/private_label.js       | 133 +++++++++++++++++++++++++++++++++++++++++
 scripts/publish_sp_channels.js |  77 ++++++++++++++++++++++++
 3 files changed, 271 insertions(+)

diff --git a/scripts/append_title.js b/scripts/append_title.js
new file mode 100644
index 0000000..86d9c13
--- /dev/null
+++ b/scripts/append_title.js
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+/**
+ * Append " | Sister Parish" to every Sister Parish product title.
+ * Idempotent — skips products whose title already ends with the suffix.
+ */
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+if (!SHOP || !TOKEN) { console.error('Need SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+const BASE = `https://${SHOP}/admin/api/2026-01`;
+const SUFFIX = ' | Sister Parish';
+const RATE_DELAY_MS = 350;
+const DRY = process.argv.includes('--dry-run');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function shopify(method, url, body) {
+  const res = await fetch(url.startsWith('http') ? url : `${BASE}${url}`, {
+    method,
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: body ? JSON.stringify(body) : undefined,
+  });
+  const text = await res.text();
+  let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
+  if (!res.ok) { const e = new Error(`${method} ${url} → ${res.status}: ${text.slice(0,300)}`); e.status = res.status; throw e; }
+  return { json, link: res.headers.get('link') || '' };
+}
+const nextPage = link => {
+  const m = link.split(',').find(s => s.includes('rel="next"'));
+  return m ? m.match(/<([^>]+)>/)[1] : null;
+};
+
+(async () => {
+  const products = [];
+  let url = `${BASE}/products.json?vendor=${encodeURIComponent('Sister Parish')}&limit=250&fields=id,title`;
+  while (url) {
+    const { json, link } = await shopify('GET', url);
+    products.push(...json.products);
+    url = nextPage(link);
+    if (url) await sleep(RATE_DELAY_MS);
+  }
+  const todo = products.filter(p => !p.title.endsWith(SUFFIX));
+  console.log(`${DRY ? '[DRY-RUN] ' : ''}${products.length} SP products, ${todo.length} need the suffix.`);
+
+  let ok = 0, fail = 0;
+  for (let i = 0; i < todo.length; i++) {
+    const p = todo[i];
+    const stamp = `[${String(i+1).padStart(3,'0')}/${todo.length}]`;
+    const newTitle = p.title + SUFFIX;
+    if (DRY) { console.log(`${stamp} "${p.title}" → "${newTitle}"`); continue; }
+    try {
+      await shopify('PUT', `/products/${p.id}.json`, { product: { id: p.id, title: newTitle } });
+      ok++; console.log(`${stamp} ✓ ${newTitle}`);
+    } catch (e) {
+      fail++; console.log(`${stamp} ✗ ${p.title}: ${e.message.slice(0,150)}`);
+    }
+    await sleep(RATE_DELAY_MS);
+  }
+  console.log(`\nUpdated: ${ok}   Failed: ${fail}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/private_label.js b/scripts/private_label.js
new file mode 100644
index 0000000..92a7cb8
--- /dev/null
+++ b/scripts/private_label.js
@@ -0,0 +1,133 @@
+#!/usr/bin/env node
+/**
+ * Private-label the Sister Parish line on the DW sandbox.
+ *  1. De-dupe: group live SP products by title, keep the richest row
+ *     (most images, tie-break lowest id), DELETE the rest.
+ *  2. Renumber: sorted by title, assign flat DWSH-10001..DWSH-100NN.
+ *     roll variant  -> DWSH-100NN
+ *     sample variant-> DWSH-100NN-S   (detected via /samp/i in old SKU)
+ *  3. Tag each kept product with dwc.series = "Saybrook House" +
+ *     dwc.series_code = "DWSH" metafields (the private-label DW collection).
+ *  4. Write output/sku_map.json (old vendor SKU -> new DW SKU, recoverable).
+ *
+ * --plan  : print the dedupe + renumber plan, change nothing.
+ */
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+if (!SHOP || !TOKEN) { console.error('Need SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+const BASE = `https://${SHOP}/admin/api/2026-01`;
+const RATE_DELAY_MS = 350;
+const SERIES = 'Saybrook House';
+const SERIES_CODE = 'DWSH';
+const START = 10001;
+const PLAN = process.argv.includes('--plan');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function shopify(method, url, body) {
+  const res = await fetch(url.startsWith('http') ? url : `${BASE}${url}`, {
+    method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: body ? JSON.stringify(body) : undefined,
+  });
+  const text = await res.text();
+  let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
+  if (!res.ok) { const e = new Error(`${method} ${url} -> ${res.status}: ${text.slice(0,300)}`); e.status = res.status; throw e; }
+  return { json, link: res.headers.get('link') || '' };
+}
+const nextPage = link => {
+  const m = link.split(',').find(s => s.includes('rel="next"'));
+  return m ? m.match(/<([^>]+)>/)[1] : null;
+};
+const isSample = v => /samp/i.test(v.sku || '') || /sample/i.test(v.option3 || '') || /sample/i.test(v.title || '');
+
+(async () => {
+  // ---- fetch all live SP products ----
+  const products = [];
+  let url = `${BASE}/products.json?vendor=${encodeURIComponent('Sister Parish')}&limit=250`;
+  while (url) {
+    const { json, link } = await shopify('GET', url);
+    products.push(...json.products);
+    url = nextPage(link);
+    if (url) await sleep(RATE_DELAY_MS);
+  }
+  console.log(`Live SP products: ${products.length}`);
+
+  // ---- dedupe: group by title ----
+  const byTitle = new Map();
+  for (const p of products) {
+    const t = p.title;
+    if (!byTitle.has(t)) byTitle.set(t, []);
+    byTitle.get(t).push(p);
+  }
+  const keepers = [], deletes = [];
+  for (const [, group] of byTitle) {
+    group.sort((a, b) => (b.images?.length || 0) - (a.images?.length || 0) || a.id - b.id);
+    keepers.push(group[0]);
+    deletes.push(...group.slice(1));
+  }
+  keepers.sort((a, b) => a.title.localeCompare(b.title));
+  console.log(`Unique titles: ${keepers.length}   Duplicates to delete: ${deletes.length}`);
+
+  // ---- build renumber plan ----
+  const map = [];
+  keepers.forEach((p, i) => {
+    const base = `${SERIES_CODE}-${START + i}`;
+    const samples = p.variants.filter(isSample);
+    const rolls = p.variants.filter(v => !isSample(v));
+    const vmap = [];
+    rolls.forEach((v, j) => vmap.push({ id: v.id, old: v.sku, new: j === 0 ? base : `${base}-R${j+1}` }));
+    samples.forEach((v, j) => vmap.push({ id: v.id, old: v.sku, new: j === 0 ? `${base}-S` : `${base}-S${j+1}` }));
+    map.push({ product_id: p.id, title: p.title, dw_base: base, variants: vmap });
+  });
+
+  if (PLAN) {
+    console.log('\n--- DELETE ---');
+    deletes.forEach(p => console.log(`  ✗ #${p.id}  ${p.title}`));
+    console.log('\n--- RENUMBER ---');
+    map.forEach(m => {
+      console.log(`  ${m.dw_base}  ${m.title}`);
+      m.variants.forEach(v => console.log(`     ${v.old}  ->  ${v.new}`));
+    });
+    return;
+  }
+
+  // ---- 1. delete dupes ----
+  let del = 0;
+  for (const p of deletes) {
+    try { await shopify('DELETE', `/products/${p.id}.json`); del++; console.log(`✗ deleted dupe #${p.id} ${p.title}`); }
+    catch (e) { console.log(`! delete failed #${p.id}: ${e.message.slice(0,120)}`); }
+    await sleep(RATE_DELAY_MS);
+  }
+  console.log(`Deleted ${del}/${deletes.length} duplicates.\n`);
+
+  // ---- 2. renumber + 3. series metafield ----
+  let ok = 0, fail = 0;
+  for (let i = 0; i < map.length; i++) {
+    const m = map[i];
+    const stamp = `[${String(i+1).padStart(3,'0')}/${map.length}]`;
+    try {
+      await shopify('PUT', `/products/${m.product_id}.json`, {
+        product: {
+          id: m.product_id,
+          variants: m.variants.map(v => ({ id: v.id, sku: v.new })),
+          metafields: [
+            { namespace: 'dwc', key: 'series', type: 'single_line_text_field', value: SERIES },
+            { namespace: 'dwc', key: 'series_code', type: 'single_line_text_field', value: SERIES_CODE },
+          ],
+        },
+      });
+      ok++; console.log(`${stamp} ✓ ${m.dw_base}  ${m.title}`);
+    } catch (e) {
+      fail++; console.log(`${stamp} ✗ ${m.title}: ${e.message.slice(0,150)}`);
+    }
+    await sleep(RATE_DELAY_MS);
+  }
+
+  fs.writeFileSync(path.join(__dirname, '..', 'output', 'sku_map.json'),
+    JSON.stringify({ generated: new Date().toISOString(), series: SERIES, series_code: SERIES_CODE, deleted: deletes.map(p => ({ id: p.id, title: p.title })), products: map }, null, 2));
+  console.log(`\nRenumbered: ${ok}   Failed: ${fail}   ·  map -> output/sku_map.json`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/publish_sp_channels.js b/scripts/publish_sp_channels.js
new file mode 100644
index 0000000..b1068ad
--- /dev/null
+++ b/scripts/publish_sp_channels.js
@@ -0,0 +1,77 @@
+#!/usr/bin/env node
+/**
+ * Publish every Sister Parish product to ALL sales channels (publications).
+ * Lists publications via GraphQL, then publishablePublish each product to all.
+ */
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+if (!SHOP || !TOKEN) { console.error('Need SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+const BASE = `https://${SHOP}/admin/api/2026-01`;
+const RATE_DELAY_MS = 350;
+const DRY = process.argv.includes('--dry-run');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function rest(method, url) {
+  const res = await fetch(url.startsWith('http') ? url : `${BASE}${url}`, {
+    method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+  });
+  const text = await res.text();
+  let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
+  if (!res.ok) { const e = new Error(`${method} ${url} → ${res.status}: ${text.slice(0,300)}`); throw e; }
+  return { json, link: res.headers.get('link') || '' };
+}
+async function gql(query, variables) {
+  const res = await fetch(`${BASE}/graphql.json`, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  const json = await res.json();
+  if (json.errors) throw new Error(JSON.stringify(json.errors).slice(0, 300));
+  return json.data;
+}
+const nextPage = link => {
+  const m = link.split(',').find(s => s.includes('rel="next"'));
+  return m ? m.match(/<([^>]+)>/)[1] : null;
+};
+
+(async () => {
+  const pubData = await gql(`{ publications(first:50){edges{node{id name}}} }`);
+  const pubs = pubData.publications.edges.map(e => e.node);
+  console.log(`${pubs.length} sales channels: ${pubs.map(p => p.name).join(', ')}`);
+
+  const products = [];
+  let url = `${BASE}/products.json?vendor=${encodeURIComponent('Sister Parish')}&limit=250&fields=id,title`;
+  while (url) {
+    const { json, link } = await rest('GET', url);
+    products.push(...json.products);
+    url = nextPage(link);
+    if (url) await sleep(RATE_DELAY_MS);
+  }
+  console.log(`${DRY ? '[DRY-RUN] ' : ''}Publishing ${products.length} SP products to all ${pubs.length} channels…`);
+  if (DRY) return;
+
+  const MUT = `mutation pub($id: ID!, $input: [PublicationInput!]!) {
+    publishablePublish(id: $id, input: $input) { userErrors { field message } }
+  }`;
+  const input = pubs.map(p => ({ publicationId: p.id }));
+
+  let ok = 0, fail = 0;
+  for (let i = 0; i < products.length; i++) {
+    const p = products[i];
+    const stamp = `[${String(i+1).padStart(3,'0')}/${products.length}]`;
+    try {
+      const d = await gql(MUT, { id: `gid://shopify/Product/${p.id}`, input });
+      const errs = d.publishablePublish.userErrors;
+      if (errs.length) { fail++; console.log(`${stamp} ⚠ ${p.title}: ${errs.map(e => e.message).join('; ').slice(0,160)}`); }
+      else { ok++; console.log(`${stamp} ✓ ${p.title}`); }
+    } catch (e) {
+      fail++; console.log(`${stamp} ✗ ${p.title}: ${e.message.slice(0,160)}`);
+    }
+    await sleep(RATE_DELAY_MS);
+  }
+  console.log(`\nPublished clean: ${ok}   With errors: ${fail}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });

← aac311d sp: check_sp_inventory.js — audit + set all 250 SP variants  ·  back to Sister Parish Onboarding  ·  sp: private_label v2 — add display_variant tag + dwc.mfr_num 2c68b15 →