← back to Greenland Onboard
scripts/classify-assign.mjs
95 lines
#!/usr/bin/env node
// Deterministic backbone: read full-catalog-skus.ndjson, (1) refine material via a
// name-keyword fallback for the "Unknown" bucket, (2) DROP cork (already live), (3)
// assign ONE coastal city per collection, (4) mint <PREFIX>-<500000+i*10> SKUs per
// colorway within each material series, (5) build mfr SKU = "<mfrModel> <City>".
// Writes data/full-map.json + prints per-material counts and the residual Unknowns.
// No enrichment yet (hex/tags/romance are the next phase).
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const rows = readFileSync(join(ROOT, 'data', 'full-catalog-skus.ndjson'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
for (const r of rows) r.material = (r.material || '').trim(); // leaf labels carried trailing spaces (PE Weaving )
const prefixMapRaw = JSON.parse(readFileSync(join(ROOT, 'data', 'material-prefix-map.json'), 'utf8'));
const prefixMap = {}; for (const [k, v] of Object.entries(prefixMapRaw)) prefixMap[k.trim()] = v;
const cities = JSON.parse(readFileSync(join(ROOT, 'data', 'coastal-cities.json'), 'utf8')).pool.slice();
// name-keyword fallback (longest/most-specific first)
const NAME_RULES = [
[/paper\s*weave/i, 'Paper Weave'], [/water\s*hyacinth/i, 'Water hyacinth'], [/cotton/i, 'Cotton Yarn'],
[/arrowroot/i, 'Arrowroot'], [/grass\s*cloth|grasscloth|\bgrass\b/i, 'Grass'], [/jacquard/i, 'Jacquard'],
[/silk/i, 'Silk'], [/suede/i, 'Suede'], [/velvet/i, 'Velvet'], [/raffia/i, 'Raffia'], [/hemp/i, 'Hemp'],
[/\bcork\b/i, 'Cork'], [/sisal/i, 'Sisal'], [/abaca/i, 'Abaca'], [/linen/i, 'Linen'], [/\bjute\b/i, 'Jute'],
[/\bwool\b/i, 'Wool'], [/\bwood\b/i, 'Wood'], [/vinyl/i, 'Vinyl'],
];
const classifyByName = (name) => { for (const [re, m] of NAME_RULES) if (re.test(name || '')) return m; return null; };
// series-code prefix fallback (S###=Silk, V###=Vinyl, P###=Paper Weave)
const classifyByCode = (code) => {
const c = (code || '').toUpperCase();
if (/^S\d/.test(c)) return 'Silk'; if (/^V\d/.test(c)) return 'Vinyl'; if (/^P\d/.test(c)) return 'Paper Weave';
return null;
};
let rescued = 0, spec = 0, metal = 0;
for (const r of rows) {
if (r.material === 'Unknown') {
const m = classifyByName(r.collectionName) || classifyByCode(r.collectionCode);
if (m) { r.material = m; rescued++; }
else if (/METAL LEAF/i.test(r.installUrl || '')) { r.material = 'Metal Leaf'; metal++; }
else { r.material = 'Specialty'; spec++; } // printed/panel goods w/ no fiber tag — Steve can re-bucket while DRAFT
}
}
// drop cork (already onboarded live as Cork-)
const build = rows.filter(r => r.material !== 'Cork');
const dropped = rows.length - build.length;
// assign one city per collection (stable order by collectionCode)
const collOrder = [...new Set(build.map(r => r.collectionCode))].sort();
const cityFor = new Map();
let ci = 0;
for (const code of collOrder) { cityFor.set(code, cities[ci] || `Coast-${ci + 1}`); ci++; }
// mint SKUs per material series (stable order), number = 500000 + i*10
const byMat = {};
for (const r of build) (byMat[r.material] ||= []).push(r);
const out = [];
const matCounts = {};
for (const [mat, list] of Object.entries(byMat)) {
const px = prefixMap[mat] || 'MISC';
list.sort((a, b) => (a.collectionCode + a.joinKey).localeCompare(b.collectionCode + b.joinKey));
let n = 500000;
for (const r of list) {
const city = cityFor.get(r.collectionCode);
const dwSku = `${px}-${n}`;
out.push({
joinKey: r.joinKey, dwSku, prefix: px, material: mat, city,
color: r.color, mfrModel: r.mfrModel, mfrSku: `${r.mfrModel} ${city}`,
collectionCode: r.collectionCode, collectionName: r.collectionName,
thumb: r.thumb, coverImg: r.coverImg,
});
n += 10;
}
matCounts[mat] = list.length;
}
writeFileSync(join(ROOT, 'data', 'full-map.json'), JSON.stringify(out, null, 0));
const stillUnknown = out.filter(r => r.material === 'Unknown');
console.log(`rescued ${rescued} via name/code; ${metal} metal-leaf, ${spec} → Specialty catch-all; dropped ${dropped} cork (already live)`);
console.log(`\n${out.length} SKUs to onboard across ${collOrder.length} collections / ${new Set(out.map(r=>r.city)).size} cities\n`);
console.log('MATERIAL PREFIX SKUS');
console.log('------------------------------------- ------- ----');
for (const [m, c] of Object.entries(matCounts).sort((a, b) => b[1] - a[1])) {
console.log(`${m.padEnd(37)} ${(prefixMap[m]||'MISC').padEnd(7)} ${String(c).padStart(4)}`);
}
if (stillUnknown.length) {
console.log(`\n⚠ ${stillUnknown.length} still Unknown (need a call). Sample collections:`);
const s = [...new Set(stillUnknown.map(r => `${r.collectionCode} ${r.collectionName}`))].slice(0, 15);
for (const x of s) console.log(' ' + x);
}
console.log(`\nwrote data/full-map.json`);