← 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`);