[object Object]

← back to Wallco Ai

spoonflower — add scripts/spoonflower_kept_to_csv.py to make --batch usable end-to-end. Reads data/designs.json + data/reviews.json (+ optional data/moodboards.json) and emits a CSV in the exact format upload_spoonflower_new.py --batch consumes. Filters: --decision keep|all, --min-pins N, --limit N, --dry (print) vs file. Tags = category + 'wallcovering' + motifs, deduped, capped at 13. Resolves image filenames against data/designs/img/ data/designs/ data/generated/. Header comment line (# image_path,title,tags) preserved through both writer and the existing reader (skipped via '#' prefix rule). +5 unit tests covering CLI, valid rows, --limit cap, tag count cap. End-to-end chain verified: generator → CSV → batch parser → 3 ready rows.

8aa0c7c32f1210451075d19f87b9035b8e74636d · 2026-05-12 06:55:10 -0700 · Steve Abrams

Files touched

Diff

commit 8aa0c7c32f1210451075d19f87b9035b8e74636d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 06:55:10 2026 -0700

    spoonflower — add scripts/spoonflower_kept_to_csv.py to make --batch usable end-to-end. Reads data/designs.json + data/reviews.json (+ optional data/moodboards.json) and emits a CSV in the exact format upload_spoonflower_new.py --batch consumes. Filters: --decision keep|all, --min-pins N, --limit N, --dry (print) vs file. Tags = category + 'wallcovering' + motifs, deduped, capped at 13. Resolves image filenames against data/designs/img/ data/designs/ data/generated/. Header comment line (# image_path,title,tags) preserved through both writer and the existing reader (skipped via '#' prefix rule). +5 unit tests covering CLI, valid rows, --limit cap, tag count cap. End-to-end chain verified: generator → CSV → batch parser → 3 ready rows.
---
 scripts/spoonflower_kept_to_csv.py | 183 +++++++++++++++++++++++++++++++++++++
 tests/unit/spoonflower-csv.test.js |  78 ++++++++++++++++
 2 files changed, 261 insertions(+)

diff --git a/scripts/spoonflower_kept_to_csv.py b/scripts/spoonflower_kept_to_csv.py
new file mode 100755
index 0000000..a0af190
--- /dev/null
+++ b/scripts/spoonflower_kept_to_csv.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+"""
+Generate a Spoonflower batch-upload CSV from the curator's kept designs.
+
+Reads:
+  data/designs.json   — canonical design catalog (id, title, image_url, motifs, ...)
+  data/reviews.json   — curator decisions { "<id>": { decision: "keep"|"reject", why, scores } }
+  data/moodboards.json (optional) — restrict to designs with at least N pins
+
+Writes a CSV that the existing upload_spoonflower_new.py --batch CLI can consume:
+  /abs/path/to/image.png,Title,"tag1,tag2"
+
+Default behavior:
+  - Only designs with decision="keep" are emitted.
+  - Tags = motifs joined by comma + "wallcovering" + category.
+  - Image paths are resolved to the local disk file under data/designs/img/.
+  - Skips designs whose file doesn't exist (logged to stderr).
+
+Usage:
+  python3 scripts/spoonflower_kept_to_csv.py [--out spoonflower-batch.csv]
+                                             [--min-pins N]
+                                             [--decision keep|all]
+                                             [--limit N]
+                                             [--dry]
+"""
+import argparse
+import csv
+import json
+import os
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+DATA = ROOT / "data"
+IMG_ROOT = DATA / "designs" / "img"
+
+
+def load_designs():
+    p = DATA / "designs.json"
+    if not p.exists():
+        sys.exit(f"error: {p} not found")
+    return {str(d["id"]): d for d in json.loads(p.read_text())}
+
+
+def load_reviews():
+    p = DATA / "reviews.json"
+    if not p.exists():
+        return {}
+    try:
+        return json.loads(p.read_text())
+    except Exception:
+        return {}
+
+
+def load_moodboards():
+    p = DATA / "moodboards.json"
+    if not p.exists():
+        return {}
+    try:
+        return json.loads(p.read_text())
+    except Exception:
+        return {}
+
+
+def resolve_image(design):
+    """Return absolute path to the local image file, or None if missing."""
+    fn = design.get("filename")
+    if not fn:
+        # Try to extract from image_url
+        url = design.get("image_url", "")
+        if "/designs/img/" in url:
+            fn = url.rsplit("/", 1)[-1]
+    if not fn:
+        return None
+    candidate = IMG_ROOT / fn
+    if not candidate.exists():
+        # Some designs may live under data/designs/ or data/generated/
+        for alt in [DATA / "designs" / fn, DATA / "generated" / fn]:
+            if alt.exists():
+                return str(alt)
+        return None
+    return str(candidate)
+
+
+def build_tags(design, extra=None):
+    """Stable, deduped, comma-separated tag string."""
+    parts = []
+    if design.get("category"):
+        parts.append(design["category"])
+    parts.append("wallcovering")
+    motifs = design.get("motifs") or []
+    if isinstance(motifs, list):
+        parts.extend(motifs)
+    if extra:
+        parts.extend(extra)
+    # Dedupe, preserve order, drop empties + non-strings
+    seen = set()
+    clean = []
+    for p in parts:
+        if not isinstance(p, str):
+            continue
+        k = p.strip().lower()
+        if not k or k in seen:
+            continue
+        seen.add(k)
+        clean.append(p.strip())
+    # Spoonflower has practical limits on tag count — cap at 13
+    return ",".join(clean[:13])
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--out", default=str(ROOT / "spoonflower-batch.csv"),
+                    help="output CSV path (default ./spoonflower-batch.csv)")
+    ap.add_argument("--min-pins", type=int, default=0,
+                    help="only emit designs with ≥ N moodboard pins")
+    ap.add_argument("--decision", choices=["keep", "all"], default="keep",
+                    help="filter by review decision (default keep)")
+    ap.add_argument("--limit", type=int, default=None,
+                    help="max rows to emit (after filters)")
+    ap.add_argument("--dry", action="store_true",
+                    help="print to stdout instead of writing file")
+    args = ap.parse_args()
+
+    designs = load_designs()
+    reviews = load_reviews()
+    moodboards = load_moodboards()
+
+    rows = []
+    skipped_missing = []
+    skipped_no_keep = []
+    skipped_min_pins = []
+
+    for did, design in designs.items():
+        decision = (reviews.get(did) or {}).get("decision")
+        if args.decision == "keep" and decision != "keep":
+            skipped_no_keep.append(did)
+            continue
+        pin_count = len(moodboards.get(did) or [])
+        if pin_count < args.min_pins:
+            skipped_min_pins.append(did)
+            continue
+        img_path = resolve_image(design)
+        if not img_path:
+            skipped_missing.append(did)
+            continue
+        title = (design.get("title") or f"wallco-{did}").strip()
+        tags = build_tags(design)
+        rows.append([img_path, title, tags])
+        if args.limit and len(rows) >= args.limit:
+            break
+
+    # Report
+    print(f"# input — {len(designs)} designs · {len(reviews)} reviews · "
+          f"{sum(len(v) for v in moodboards.values())} moodboard pins",
+          file=sys.stderr)
+    print(f"# emitting — {len(rows)} rows", file=sys.stderr)
+    if skipped_no_keep:
+        print(f"# skipped {len(skipped_no_keep)} (no keep decision)", file=sys.stderr)
+    if skipped_min_pins:
+        print(f"# skipped {len(skipped_min_pins)} (below --min-pins)", file=sys.stderr)
+    if skipped_missing:
+        print(f"# skipped {len(skipped_missing)} (image file missing)", file=sys.stderr)
+
+    if args.dry:
+        w = csv.writer(sys.stdout)
+        w.writerow(["# image_path", "title", "tags"])
+        for r in rows:
+            w.writerow(r)
+        return 0
+
+    out = Path(args.out)
+    with out.open("w", newline="") as f:
+        w = csv.writer(f)
+        w.writerow(["# image_path", "title", "tags"])
+        for r in rows:
+            w.writerow(r)
+    print(f"wrote {len(rows)} rows → {out}", file=sys.stderr)
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tests/unit/spoonflower-csv.test.js b/tests/unit/spoonflower-csv.test.js
new file mode 100644
index 0000000..6254547
--- /dev/null
+++ b/tests/unit/spoonflower-csv.test.js
@@ -0,0 +1,78 @@
+'use strict';
+// Smoke test for scripts/spoonflower_kept_to_csv.py — the CSV its --batch
+// counterpart consumes. Confirms:
+//   1. CLI runs without syntax errors
+//   2. --decision all + --dry produces a CSV header + rows
+//   3. Each row has 3 fields (image_path, title, tags)
+//   4. The first field is an absolute path that exists on disk
+//   5. Header comment is preserved so the consumer skips it correctly
+//
+// Run with: node --test tests/unit/spoonflower-csv.test.js
+
+const { test, describe } = require('node:test');
+const assert = require('node:assert/strict');
+const { execFileSync } = require('node:child_process');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'spoonflower_kept_to_csv.py');
+
+function run(args) {
+  return execFileSync('python3', [SCRIPT, ...args], {
+    encoding: 'utf8',
+    stdio: ['ignore', 'pipe', 'ignore'], // suppress stderr counts
+  });
+}
+
+describe('spoonflower_kept_to_csv.py', () => {
+  test('--help works', () => {
+    const out = execFileSync('python3', [SCRIPT, '--help'], { encoding: 'utf8' });
+    assert.match(out, /--batch|--decision|--limit|--dry/);
+  });
+
+  test('--dry --decision=all emits valid CSV rows', () => {
+    const out = run(['--decision', 'all', '--limit', '5', '--dry']);
+    const lines = out.trim().split('\n').filter(Boolean);
+    assert.ok(lines.length >= 2, 'has header + at least one row');
+    assert.match(lines[0], /image_path|title|tags/, 'first line is the header comment');
+    // Skip the header (line[0] starts with #) and check the rest
+    const rows = lines.slice(1);
+    for (const row of rows) {
+      // Tags may contain commas inside quotes, so just check we have at least the
+      // first two unquoted commas (path,title,...)
+      const firstComma = row.indexOf(',');
+      const secondComma = row.indexOf(',', firstComma + 1);
+      assert.ok(firstComma > 0 && secondComma > firstComma, `row has ≥ 2 commas: ${row}`);
+      const imgPath = row.slice(0, firstComma);
+      assert.ok(imgPath.startsWith('/'), `image path is absolute: ${imgPath}`);
+      assert.ok(fs.existsSync(imgPath), `image file exists on disk: ${imgPath}`);
+    }
+  });
+
+  test('--decision=keep skips when no kept designs exist', () => {
+    const out = run(['--decision', 'keep', '--dry']);
+    const lines = out.trim().split('\n').filter(Boolean);
+    // Header always emitted; rows may be 0
+    assert.ok(lines.length >= 1, 'header present');
+  });
+
+  test('--limit caps emitted rows', () => {
+    const out = run(['--decision', 'all', '--limit', '2', '--dry']);
+    const lines = out.trim().split('\n').filter(Boolean);
+    const rows = lines.slice(1); // strip header
+    assert.ok(rows.length <= 2, `--limit 2 yielded ${rows.length} rows`);
+  });
+
+  test('tags are comma-separated and capped at ≤13 entries', () => {
+    const out = run(['--decision', 'all', '--limit', '5', '--dry']);
+    const rows = out.trim().split('\n').slice(1).filter(Boolean);
+    for (const row of rows) {
+      // The tags field is the last column — find it by reverse-parsing
+      // Simple heuristic: split on commas inside the LAST quoted block
+      const m = row.match(/,"([^"]*)"$/) || row.match(/,([^,"]*)$/);
+      if (!m) continue;
+      const tagCount = m[1].split(',').filter(Boolean).length;
+      assert.ok(tagCount <= 13, `≤13 tags per row, got ${tagCount}: ${m[1]}`);
+    }
+  });
+});

← 680a09e wallco.ai · /design/:id Share button — Web Share API + clipb  ·  back to Wallco Ai  ·  spoonflower — add GET /api/export/spoonflower-batch.csv + 'S 29adb76 →