← back to Reid Witlin Onboarding

rename_titles_fabric.py

130 lines

#!/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()