← back to Reid Witlin Onboarding
Reid Witlin/Architectural Fabrics: rename titles to Pattern/Town - Colorway Fabric
ef19926ff3b773dded82d26fda6e95489282b133 · 2026-09-03 11:42:21 -0700 · Steve Abrams
Steve approved live: title format "{Pattern-or-Town} - {Colorway} Fabric"
(e.g. "Ashburn Cassis" -> "Ashburn - Cassis Fabric"). Rebuilt titles from
structured catalog data (pattern_name/virginia_town + mfr_sku-derived
colorway) rather than string-splitting rendered titles, with a casing
fix for ALL-CAPS raw vendor pattern names (e.g. "A ZILLION RUBS" ->
"A Zillion Rubs"). Scoped to ACTIVE products only (live status-checked
per row) -- 16 archived/discontinued rows correctly skipped.
Dry-run verified clean, live run: 1179 renamed, 16 skipped (not active),
0 errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Files touched
M rename-titles-fabric-results.jsonA rename_titles_fabric.py
Diff
commit ef19926ff3b773dded82d26fda6e95489282b133
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 3 11:42:21 2026 -0700
Reid Witlin/Architectural Fabrics: rename titles to Pattern/Town - Colorway Fabric
Steve approved live: title format "{Pattern-or-Town} - {Colorway} Fabric"
(e.g. "Ashburn Cassis" -> "Ashburn - Cassis Fabric"). Rebuilt titles from
structured catalog data (pattern_name/virginia_town + mfr_sku-derived
colorway) rather than string-splitting rendered titles, with a casing
fix for ALL-CAPS raw vendor pattern names (e.g. "A ZILLION RUBS" ->
"A Zillion Rubs"). Scoped to ACTIVE products only (live status-checked
per row) -- 16 archived/discontinued rows correctly skipped.
Dry-run verified clean, live run: 1179 renamed, 16 skipped (not active),
0 errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
rename-titles-fabric-results.json | 4 +-
rename_titles_fabric.py | 129 ++++++++++++++++++++++++++++++++++++++
2 files changed, 131 insertions(+), 2 deletions(-)
diff --git a/rename-titles-fabric-results.json b/rename-titles-fabric-results.json
index 2110d5d..cd08279 100644
--- a/rename-titles-fabric-results.json
+++ b/rename-titles-fabric-results.json
@@ -1,6 +1,6 @@
{
- "renamed": 1195,
- "skipped_not_active": 0,
+ "renamed": 1179,
+ "skipped_not_active": 16,
"errors": [],
"total_candidates": 1195
}
\ No newline at end of file
diff --git a/rename_titles_fabric.py b/rename_titles_fabric.py
new file mode 100644
index 0000000..1b8d76e
--- /dev/null
+++ b/rename_titles_fabric.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+"""
+Reid Witlin / Architectural Fabrics — rewrite every ACTIVE product's title to
+"{Pattern-or-Town} - {Colorway} Fabric" (Steve approved format 2026-09-03).
+
+Rebuilds the title from STRUCTURED catalog data (pattern_name / virginia_town +
+colorway derived from mfr_sku) rather than string-splitting the already-rendered
+title, since some titles use town-based naming and others pattern-based, and
+some patterns/colorways are multi-word.
+
+Scope: only ACTIVE rwltd_catalog products (archived/discontinued items are
+skipped — no point renaming something no longer customer-facing).
+
+SAFETY: DRY_RUN=1 default. Title-only productUpdate, nothing else touched.
+"""
+import os, re, json, time, subprocess, urllib.request
+
+DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
+LIMIT = int(os.environ.get("LIMIT", "0")) or None
+
+def _tok():
+ p = os.path.expanduser("~/Projects/secrets-manager/.env")
+ for line in open(p):
+ if line.startswith("SHOPIFY_ADMIN_TOKEN="):
+ return line.split("=", 1)[1].strip().strip('"')
+ raise SystemExit("SHOPIFY_ADMIN_TOKEN not found")
+
+TOKEN = os.environ.get("AT") or _tok()
+URL = "https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json"
+
+def gql(q, v=None):
+ body = json.dumps({"query": q, "variables": v or {}}).encode()
+ req = urllib.request.Request(URL, body,
+ {"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
+ for a in range(8):
+ try:
+ d = json.load(urllib.request.urlopen(req, timeout=90))
+ if "errors" in d and any("THROTTLED" in str(e) for e in d["errors"]):
+ time.sleep(2 * (a + 1)); continue
+ return d
+ except Exception:
+ time.sleep(2 * (a + 1))
+ raise RuntimeError("gql failed")
+
+def slugify(s):
+ return re.sub(r'-+', '-', re.sub(r'[^a-z0-9]+', '-', (s or '').lower())).strip('-')
+
+def colorway_of(mfr_sku, pattern_name):
+ pat = slugify(pattern_name)
+ slug = slugify(mfr_sku)
+ if pat and slug.startswith(pat + '-'):
+ return slug[len(pat) + 1:]
+ return slug.rsplit('-', 1)[-1]
+
+def psql_json(sql):
+ wrapped = f"SELECT COALESCE(json_agg(t),'[]') FROM ({sql}) t;"
+ r = subprocess.run(["psql", "host=/tmp dbname=dw_unified", "-tA", "-c", wrapped],
+ capture_output=True, text=True)
+ if r.returncode != 0:
+ raise SystemExit(f"psql failed: {r.stderr}")
+ return json.loads(r.stdout.strip() or "[]")
+
+PRODUCT_STATUS_Q = "query($id: ID!) { product(id: $id) { status } }"
+RENAME_M = """
+mutation($input: ProductInput!) {
+ productUpdate(input: $input) { product { id title } userErrors { field message } }
+}"""
+
+def to_gid(raw):
+ return raw if raw.startswith("gid://") else f"gid://shopify/Product/{raw}"
+
+def main():
+ rows = psql_json("""
+ SELECT mfr_sku, pattern_name, virginia_town, shopify_product_id
+ FROM rwltd_catalog
+ WHERE shopify_product_id IS NOT NULL AND shopify_product_id <> ''
+ ORDER BY mfr_sku
+ """)
+ if LIMIT:
+ rows = rows[:LIMIT]
+
+ candidates = []
+ for row in rows:
+ pat = row.get("pattern_name") or ""
+ town = (row.get("virginia_town") or "").strip()
+ mfr = row["mfr_sku"]
+ cw = colorway_of(mfr, pat).replace('-', ' ').title()
+ base = town or (pat.title() if pat.isupper() else pat)
+ if not base or not cw:
+ continue
+ new_title = f"{base} - {cw} Fabric"
+ candidates.append({"gid": to_gid(row["shopify_product_id"]), "new_title": new_title, "mfr_sku": mfr})
+
+ print(f"{'DRY-RUN' if DRY_RUN else 'LIVE'}: {len(candidates)} candidate rows "
+ f"(will skip any not currently ACTIVE)")
+
+ renamed, skipped_not_active, errs = 0, 0, []
+ for i, c in enumerate(candidates):
+ if DRY_RUN:
+ if i < 8:
+ print(f" {c['mfr_sku']:30s} -> {c['new_title']}")
+ renamed += 1
+ continue
+ st = gql(PRODUCT_STATUS_Q, {"id": c["gid"]})
+ status = ((st.get("data") or {}).get("product") or {}).get("status")
+ if status != "ACTIVE":
+ skipped_not_active += 1
+ continue
+ d = gql(RENAME_M, {"input": {"id": c["gid"], "title": c["new_title"]}})
+ r = (d.get("data") or {}).get("productUpdate") or {}
+ ue = r.get("userErrors") or []
+ if ue:
+ errs.append({"gid": c["gid"], "mfr_sku": c["mfr_sku"], "errors": ue[:2]})
+ else:
+ renamed += 1
+ if i % 50 == 0:
+ print(f" ...{i}/{len(candidates)} renamed={renamed} skipped_not_active={skipped_not_active} errs={len(errs)}")
+ time.sleep(0.3)
+
+ out = {"renamed": renamed, "skipped_not_active": skipped_not_active,
+ "errors": errs[:20], "total_candidates": len(candidates)}
+ json.dump(out, open("rename-titles-fabric-results.json", "w"), indent=2)
+ print(f"\nDONE. renamed={renamed} skipped_not_active={skipped_not_active} "
+ f"userErrors={len(errs)} ({'DRY-RUN — nothing written' if DRY_RUN else 'LIVE titles updated'})")
+ if errs:
+ print("sample errors:", json.dumps(errs[:3]))
+
+if __name__ == "__main__":
+ main()
← b4d14f1 auto-data-snapshot: 2026-09-03T11:10:51 (1 data files) — ren
·
back to Reid Witlin Onboarding
·
auto-data-snapshot: 2026-09-03T15:07:41 (1 data files) — rwl 761eab8 →