← back to Dw Slideshow Gen
lib/catalog.js
147 lines
// lib/catalog.js — build a slideshow queue straight from the live dw_unified catalog.
// Shells out to the local passwordless `psql -d dw_unified` (no `pg` dependency),
// parses each product's tags into facets, writes real editorial body copy, and
// downloads the lead product photo to use as a slide background.
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { ensureDir, slugify, titleCase, cap } from './util.js'
function psqlJSON(sql) {
const out = execFileSync('psql', ['-d', 'dw_unified', '-tAc', sql],
{ encoding: 'utf8', maxBuffer: 1 << 26 }).trim()
return out ? JSON.parse(out) : []
}
// ---- tag -> facets ---------------------------------------------------------
const STYLES = ['Chinoiserie', 'Damask', 'Toile', 'Botanical', 'Floral', 'Geometric',
'Traditional', 'Modern', 'Contemporary', 'Art Deco', 'Grasscloth', 'Cottagecore',
'Animal', 'Stripe', 'Ikat', 'Trellis', 'Abstract', 'Tropical', 'Moroccan']
const MATERIALS = ['Grasscloth', 'Raffia', 'Linen', 'Silk', 'Cork', 'Vinyl', 'Mylar',
'Non Woven', 'Sisal', 'Paperweave', 'Mica', 'Leather', 'Wool', 'Cotton', 'Velvet', 'Suede']
const ROOMS = ['Bedroom', 'Dining Room', 'Living Room', 'Powder Room', 'Bathroom',
'Entry', 'Office', 'Nursery', 'Hallway', 'Kitchen']
const decode = s => String(s || '')
.replace(/'|'/g, "'").replace(/&/g, '&').replace(/"/g, '"').trim()
function facets(tagStr) {
const tags = decode(tagStr).split(',').map(t => t.trim()).filter(Boolean)
const f = { color: null, style: [], material: null, rooms: [], match: null }
for (const t of tags) {
const m = t.match(/^colou?r\s*[:=]\s*(.+)/i)
if (m && !f.color) { f.color = titleCase(m[1].trim()); continue }
if (/(straight|half\s*drop|random|free)\s*match/i.test(t)) f.match = t
for (const s of STYLES) if (t.toLowerCase() === s.toLowerCase() && !f.style.includes(s)) f.style.push(s)
for (const mat of MATERIALS) if (new RegExp(mat, 'i').test(t) && !f.material) f.material = t
for (const r of ROOMS) if (t.toLowerCase() === r.toLowerCase() && !f.rooms.includes(r)) f.rooms.push(r)
}
return f
}
function patternName(title) {
let t = decode(title).split('|')[0].trim() // drop "| Vendor"
t = t.split(/\s+[-–]\s+/)[0].trim() // drop "- colorway/desc"
t = t.replace(/\b(Wallcovering|Wallpaper|Fabric)\b\s*$/i, '').trim()
return t || decode(title)
}
function factsFromFacets(f, type) {
const out = []
if (f.material) out.push({ title: 'The texture', subtitle: cap(`${titleCase(f.material)} brings real depth to the wall — texture a flat paint can't touch.`, 130) })
if (f.color) out.push({ title: 'The colorway', subtitle: cap(`Shown in ${f.color} — a tone that reads warm and intentional in any light.`, 130) })
if (f.style.length) out.push({ title: 'The look', subtitle: cap(`${titleCase(f.style.slice(0, 2).join(' · '))} — a pattern that anchors a room without shouting.`, 130) })
if (f.rooms.length) out.push({ title: 'Where it works', subtitle: cap(`Designers reach for it in the ${f.rooms.slice(0, 2).join(' and ').toLowerCase()}.`, 130) })
if (f.match) out.push({ title: 'Easy install', subtitle: cap(`${titleCase(f.match)} for a clean, professional hang.`, 130) })
return out.slice(0, 4)
}
async function download(url, dest) {
const res = await fetch(url, { redirect: 'follow' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const buf = Buffer.from(await res.arrayBuffer())
ensureDir(path.dirname(dest)); fs.writeFileSync(dest, buf)
return dest
}
// ---- queue builders --------------------------------------------------------
export async function buildCatalogQueue(opts = {}) {
const { vendor, type, limit = 6, since, mode = 'spotlight', bgDir, photos = true } = opts
const where = [
"status='active'", 'online_store_published is true',
"image_url like 'http%'",
'tags is not null', 'length(tags) > 10', // tags drive the slide copy; mirror lacks body_html
]
if (vendor) where.push(`vendor = '${vendor.replace(/'/g, "''")}'`)
if (type) where.push(`product_type = '${type.replace(/'/g, "''")}'`)
if (since) where.push(`created_at_shopify >= '${since.replace(/'/g, "''")}'`)
const sql = `select json_agg(t) from (
select id, title, vendor, product_type, tags, image_url, dw_sku,
created_at_shopify::date as created
from shopify_products
where ${where.join(' and ')}
order by created_at_shopify desc nulls last
limit ${Number(limit) || 6}
) t`
const rows = psqlJSON(sql) || []
if (!rows.length) throw new Error('no matching catalog products')
if (mode === 'roundup') return [await buildRoundup(rows, opts)]
// spotlight: one carousel per product
const items = []
for (const r of rows) {
const f = facets(r.tags)
const pat = patternName(r.title)
let body = factsFromFacets(f, r.product_type)
const backgrounds = {}
if (photos) {
try {
const dest = path.join(bgDir, `${r.id}.jpg`)
await download(r.image_url, dest)
backgrounds['1'] = dest; backgrounds['6'] = dest // photo on hook + CTA
} catch (e) { process.stderr.write(` [catalog] img ${r.id} failed: ${e.message}\n`) }
}
items.push({
id: slugify(`${pat}-${r.dw_sku || r.id}`),
topic: pat,
angle: `${r.vendor} · ${r.product_type}`,
style: f.style[0] || null,
colorway: f.color || null,
room: f.rooms[0] || null,
slides: body.length >= 2 ? body : undefined, // else hooks.js writes generic body
backgrounds,
cta: { label: 'Shop this pattern', sub: 'designerwallcoverings.com' },
hashtags: dedupeTags(['#designerwallcoverings', tagify(r.vendor), tagify(f.material), tagify(f.style[0]), tagify(r.product_type)]),
})
}
return items
}
async function buildRoundup(rows, opts) {
const { bgDir, photos = true } = opts
const vendor = rows[0].vendor
const type = rows[0].product_type || 'Wallcovering'
const body = []
const backgrounds = {}
for (let i = 0; i < Math.min(4, rows.length); i++) {
const r = rows[i], f = facets(r.tags), pat = patternName(r.title)
body.push({ title: cap(pat, 38), subtitle: cap(`${f.color ? f.color + ' · ' : ''}${f.material ? titleCase(f.material) + ' · ' : ''}${f.style[0] || type}.`, 130) })
if (photos) {
try { const dest = path.join(bgDir, `roundup-${r.id}.jpg`); await download(r.image_url, dest); backgrounds[String(i + 2)] = dest }
catch {}
}
}
return {
id: slugify(`new-${vendor}-arrivals`),
topic: `New ${vendor} arrivals`,
angle: `${rows.length} just landed`,
slides: body,
backgrounds,
cta: { label: 'Shop new arrivals', sub: 'designerwallcoverings.com' },
hashtags: dedupeTags(['#designerwallcoverings', tagify(vendor), '#newarrivals', tagify(type), '#interiordesign']),
}
}
const tagify = s => s ? '#' + slugify(s).replace(/-/g, '') : null
const dedupeTags = arr => [...new Set(arr.filter(Boolean))].slice(0, 5)