[object Object]

← 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

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 →