[object Object]

← back to Wallco Ai

spoonflower — add GET /api/export/spoonflower-batch.csv + 'Spoonflower CSV' link on /moodboard. JS-native equivalent of scripts/spoonflower_kept_to_csv.py — same source data (reviews.json + designs.json), same output format (image_path,title,tags with category+wallcovering+motifs tags capped at 13), same '# header' first line. X-Wallco-Rows + X-Wallco-Skipped-Missing-Image response headers for client diagnostics. Now curators can one-click the spoonflower-batch CSV from /moodboard instead of running a Python CLI. Verified live with 2 seeded keeps → 2 rows; 0 keeps → 0 data rows + header.

29adb76be98737d289c5f06b71d54b1f2b514f90 · 2026-05-12 06:57:04 -0700 · Steve Abrams

Files touched

Diff

commit 29adb76be98737d289c5f06b71d54b1f2b514f90
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 06:57:04 2026 -0700

    spoonflower — add GET /api/export/spoonflower-batch.csv + 'Spoonflower CSV' link on /moodboard. JS-native equivalent of scripts/spoonflower_kept_to_csv.py — same source data (reviews.json + designs.json), same output format (image_path,title,tags with category+wallcovering+motifs tags capped at 13), same '# header' first line. X-Wallco-Rows + X-Wallco-Skipped-Missing-Image response headers for client diagnostics. Now curators can one-click the spoonflower-batch CSV from /moodboard instead of running a Python CLI. Verified live with 2 seeded keeps → 2 rows; 0 keeps → 0 data rows + header.
---
 server.js     | 31 ++++++++++++++++++++---------
 src/review.js | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 85 insertions(+), 9 deletions(-)

diff --git a/server.js b/server.js
index fbd7609..3b97d04 100644
--- a/server.js
+++ b/server.js
@@ -232,6 +232,18 @@ function titleFor(cat, hex, id) {
   return `${hueName(hex)} ${pool[id % pool.length]} No.${id}`;
 }
 
+// Card aria-label — folds title/category/dominant hue + room-preview count into
+// a single sentence so screen readers announce all the visible affordances of a
+// card in one read. Replaces the silence on the background-image divs.
+function cardAriaLabel(d) {
+  const hue = hueName(d.dominant_hex || '').toLowerCase();
+  const cat = (d.category || 'pattern').toLowerCase();
+  const rooms = (d.room_mockups || []).length;
+  const roomTxt = rooms > 1 ? `, ${rooms} room previews available`
+                 : rooms === 1 ? ', 1 room preview available' : '';
+  return `${d.title} — ${hue} ${cat} wallpaper${roomTxt}. View details.`;
+}
+
 function sortDesigns(designs, sort) {
   const d = [...designs];
   // Designs with a pre-generated room preview surface first within any sort —
@@ -801,12 +813,12 @@ app.get('/', (req, res) => {
       ? `<img class="card-room" src="/designs/room/design_${d.id}_${roomKey}.png" loading="lazy" alt="" aria-hidden="true"><span class="card-room-badge">${badgeText}</span>`
       : '';
     return `
-    <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}">
-      <div class="card-img" style="background-image:url('${d.image_url}')">${roomOverlay}</div>
+    <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}" aria-label="${cardAriaLabel(d)}">
+      <div class="card-img" style="background-image:url('${d.image_url}')" role="img" aria-label="${d.title} pattern thumbnail">${roomOverlay}</div>
       <div class="card-meta">
         <span class="card-title">${d.title}</span>
         <span class="card-cat">${d.category}</span>
-        <span class="color-dot" style="background:${d.dominant_hex}"></span>
+        <span class="color-dot" style="background:${d.dominant_hex}" aria-hidden="true"></span>
       </div>
     </a>`;
   }).join('');
@@ -974,12 +986,12 @@ app.get('/designs', (req, res) => {
         ? `<img class="card-room" src="/designs/room/design_${d.id}_${roomKey}.png" loading="lazy" alt="" aria-hidden="true"><span class="card-room-badge">${badgeText}</span>`
         : '';
       return `
-    <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}">
-      <div class="card-img" style="background-image:url('${d.image_url}')">${roomOverlay}</div>
+    <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}" aria-label="${cardAriaLabel(d)}">
+      <div class="card-img" style="background-image:url('${d.image_url}')" role="img" aria-label="${d.title} pattern thumbnail">${roomOverlay}</div>
       <div class="card-meta">
         <span class="card-title">${d.title}</span>
         <span class="card-cat">${d.category}</span>
-        <span class="color-dot" style="background:${d.dominant_hex}"></span>
+        <span class="color-dot" style="background:${d.dominant_hex}" aria-hidden="true"></span>
       </div>
     </a>`;
     }
@@ -3939,12 +3951,12 @@ app.get('/murals', (req, res) => {
       ? `<img class="card-room" src="/designs/room/design_${d.id}_${roomKey}.png" loading="lazy" alt="" aria-hidden="true"><span class="card-room-badge">${badgeText}</span>`
       : '';
     return `
-    <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}">
-      <div class="card-img" style="background-image:url('${d.image_url}')">${roomOverlay}</div>
+    <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}" aria-label="${cardAriaLabel(d)}">
+      <div class="card-img" style="background-image:url('${d.image_url}')" role="img" aria-label="${d.title} pattern thumbnail">${roomOverlay}</div>
       <div class="card-meta">
         <span class="card-title">${d.title}</span>
         <span class="card-cat">${murals.length === 0 ? 'available as mural' : d.kind}</span>
-        <span class="color-dot" style="background:${d.dominant_hex}"></span>
+        <span class="color-dot" style="background:${d.dominant_hex}" aria-hidden="true"></span>
       </div>
     </a>`;
   }).join('');
@@ -4341,6 +4353,7 @@ app.get('/moodboard', (req, res) => {
     <a class="filter-pill ${filter==='color'?'on':''}" href="/moodboard?type=color">Colors</a>
     <a class="filter-pill ${filter==='material'?'on':''}" href="/moodboard?type=material">Materials</a>
     <a class="export-link" href="/api/export/keep.csv" download>Kept CSV</a>
+    <a class="export-link" href="/api/export/spoonflower-batch.csv" download title="Spoonflower-batch CSV — feeds upload_spoonflower_new.py --batch">Spoonflower CSV</a>
   </div>
 </header>
 <main>
diff --git a/src/review.js b/src/review.js
index 989fdc3..e517a1d 100644
--- a/src/review.js
+++ b/src/review.js
@@ -592,6 +592,69 @@ function mount(app, getDesigns) {
     res.send(rows.join('\n') + '\n');
   });
 
+  // CSV export — Spoonflower-batch format (image_path,title,tags) for kept designs.
+  // Companion endpoint to scripts/spoonflower_kept_to_csv.py — same logic but UI-friendly.
+  // Output feeds upload_spoonflower_new.py --batch directly.
+  app.get('/api/export/spoonflower-batch.csv', requireAdmin, (req, res) => {
+    const reviews = loadJSON(REVIEWS_FILE, {});
+    const designs = getDesigns();
+    const IMG_ROOT = path.join(__dirname, '..', 'data', 'designs', 'img');
+    const ALT_ROOTS = [
+      path.join(__dirname, '..', 'data', 'designs'),
+      path.join(__dirname, '..', 'data', 'generated'),
+    ];
+
+    function resolveImage(d) {
+      let fn = d.filename;
+      if (!fn && d.image_url && d.image_url.includes('/designs/img/')) {
+        fn = d.image_url.split('/').pop();
+      }
+      if (!fn) return null;
+      const candidates = [path.join(IMG_ROOT, fn), ...ALT_ROOTS.map(r => path.join(r, fn))];
+      for (const c of candidates) {
+        try { if (fs.statSync(c).isFile()) return c; } catch {}
+      }
+      return null;
+    }
+
+    function buildTags(d) {
+      const parts = [];
+      if (d.category) parts.push(d.category);
+      parts.push('wallcovering');
+      if (Array.isArray(d.motifs)) parts.push(...d.motifs);
+      const seen = new Set(); const clean = [];
+      for (const p of parts) {
+        if (typeof p !== 'string') continue;
+        const k = p.trim().toLowerCase();
+        if (!k || seen.has(k)) continue;
+        seen.add(k); clean.push(p.trim());
+      }
+      return clean.slice(0, 13).join(',');
+    }
+
+    const rows = ['# image_path,title,tags'];
+    let kept = 0, skippedMissing = 0;
+    for (const [id, r] of Object.entries(reviews)) {
+      if (r.decision !== 'keep') continue;
+      const d = designs.find(x => String(x.id) === String(id));
+      if (!d) continue;
+      const img = resolveImage(d);
+      if (!img) { skippedMissing++; continue; }
+      const title = (d.title || ('wallco-' + id)).replace(/"/g, '""');
+      const tags = buildTags(d).replace(/"/g, '""');
+      // CSV: quote each field if it contains a comma. Path won't, title might
+      // contain commas; tags will (intentionally).
+      const fld = (s) => /[",\n]/.test(s) ? '"' + s + '"' : s;
+      rows.push([fld(img), fld(title), fld(tags)].join(','));
+      kept++;
+    }
+    res.type('text/csv')
+       .setHeader('Content-Disposition', 'attachment; filename="spoonflower-batch-' + new Date().toISOString().slice(0,10) + '.csv"')
+       .setHeader('X-Wallco-Rows', String(kept))
+       .setHeader('X-Wallco-Skipped-Missing-Image', String(skippedMissing))
+       .send(rows.join('\n') + '\n');
+  });
+
   console.log('  Review layer mounted (sliders + chips + chat + pairings + moodboard + CSV export on Ollama ' + OLLAMA_MODEL + ' / ' + VISION_MODEL + ')');
 }
 

← 8aa0c7c spoonflower — add scripts/spoonflower_kept_to_csv.py to make  ·  back to Wallco Ai  ·  wallco.ai · WCAG 2.4.1 skip-to-main link in htmlHeader() — f 2acf00a →