← 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
M server.jsM src/review.js
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 →