← 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
A scripts/spoonflower_kept_to_csv.pyA tests/unit/spoonflower-csv.test.js
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 →