← back to Pattern Vault
scripts/extend-kits-inhouse.mjs
108 lines
#!/usr/bin/env node
// Extend the marketplace upload kits (spoonflower / etsy / creative-market) to cover
// the in-house-original designs that were added to the catalog after the initial
// 24-design WPB kit build. Idempotent: re-running skips designs already present in a CSV.
//
// - Converts each in-house web preview (public/assets/pilot/<x>.webp) -> kits/_images/ih-<x>.png
// via macOS `sips` ($0 local). These are listing PREVIEWS; the 150-DPI print masters remain
// a gated Kamatera pull at actual upload time (see kits/README.md).
// - Appends native-schema rows to all three kit CSVs.
//
// Usage: node scripts/extend-kits-inhouse.mjs
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const catalog = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/catalog.json'), 'utf8'));
const inhouse = catalog.filter((d) => d.category === 'in-house-original');
const PRICE = 149;
const LICENSE_LINE =
'Original seamless repeat from the Wallpaper’s Back studio — seam-verified, print-clean at 150 DPI. ' +
'Digital single-use license included; commercial and exclusive tiers available.';
// CSV field: quote if it contains comma/quote/newline; double internal quotes.
const q = (v) => {
const s = String(v ?? '');
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
// Derive a short style descriptor + tag set from title + styleLine.
const styleOf = (d) => {
const parts = d.title.split('—').map((s) => s.trim()); // em-dash split
return parts.length > 1 ? parts[parts.length - 1] : d.title;
};
const tagsOf = (d) => {
const style = styleOf(d).toLowerCase();
const words = style.split(/[\s/]+/).filter((w) => w.length > 2);
const base = ['seamless pattern', 'original design', 'wallpaper', 'surface design', 'repeat pattern', 'print ready'];
// Etsy caps at 13 tags; keep it tight and deduped.
return [...new Set([...words, ...base])].slice(0, 13);
};
// Strip internal provenance/legal-gate jargon from customer-facing copy.
const sanitize = (s) =>
String(s || '')
.replace(/settlement[- ]?(post[- ]?gen )?pass(ed)?\.?/gi, '') // "Settlement-passed", "Settlement post-gen PASS"
.replace(/\s*\(\s*\)\s*/g, ' ') // empty parens left behind
.replace(/\s+([.,])/g, '$1') // space before punctuation
.replace(/\s+/g, ' ')
.trim();
const descOf = (d) => sanitize(`${sanitize(d.styleLine)} ${LICENSE_LINE}`);
const slugOf = (d) => 'ih-' + path.basename(d.img).replace(/\.[a-z]+$/i, '');
// 1) Convert previews -> PNG in kits/_images/
const imgDir = path.join(ROOT, 'kits/_images');
fs.mkdirSync(imgDir, { recursive: true });
let converted = 0;
for (const d of inhouse) {
const src = path.join(ROOT, 'public', d.img); // /assets/pilot/x.webp
const out = path.join(imgDir, slugOf(d) + '.png');
if (fs.existsSync(out)) continue;
if (!fs.existsSync(src)) {
console.warn(' ! missing preview, skipping:', d.id, src);
continue;
}
execFileSync('sips', ['-s', 'format', 'png', src, '--out', out], { stdio: 'ignore' });
converted++;
}
// 2) Append rows to each kit CSV (skip designs already present by design_file).
const kits = {
'kits/spoonflower/listings.csv': (d) => {
const file = `_images/${slugOf(d)}.png`;
return { key: file, row: [file, d.title, descOf(d), tagsOf(d).join(', ')].map(q).join(',') };
},
'kits/etsy/listings.csv': (d) => {
const file = `_images/${slugOf(d)}.png`;
const row = [file, `${d.title} — Seamless Digital Pattern`, descOf(d), PRICE, 999,
tagsOf(d).join(','), 'i_did', '2020_2026', 'TRUE'].map(q).join(',');
return { key: file, row };
},
'kits/creative-market/listings.csv': (d) => {
const file = `_images/${slugOf(d)}.png`;
const subtitle = descOf(d).slice(0, 80);
const row = [file, d.title, subtitle, descOf(d), 'patterns', PRICE, tagsOf(d).join(', ')].map(q).join(',');
return { key: file, row };
},
};
const summary = {};
for (const [rel, build] of Object.entries(kits)) {
const p = path.join(ROOT, rel);
// Drop any prior in-house (ih-*) rows so regenerated copy replaces them; keep header + WPB rows.
const kept = fs
.readFileSync(p, 'utf8')
.split('\n')
.filter((line) => line.length && !/(^|,)"?_images\/ih-/.test(line));
const rows = inhouse.map((d) => build(d).row);
fs.writeFileSync(p, kept.join('\n') + '\n' + rows.join('\n') + '\n');
summary[rel] = rows.length;
}
console.log(`in-house designs: ${inhouse.length}`);
console.log(`previews converted to PNG: ${converted}`);
for (const [k, v] of Object.entries(summary)) console.log(` + ${v} rows -> ${k}`);