238 specs (CH/CZ/DE/NORDICS), dark/light theme, cascading filters, side-by-side comparison, checklist export, admin panel, bulk import. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
7.9 KiB
JavaScript
152 lines
7.9 KiB
JavaScript
'use strict';
|
|
|
|
const express = require('express');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const multer = require('multer');
|
|
const XLSX = require('xlsx');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
|
|
const app = express();
|
|
const PORT = process.env.ADMIN_TOKEN || 3101;
|
|
const ADMIN_TOKEN= process.env.ADMIN_TOKEN || 'loreal2024';
|
|
const STATIC_DIR = path.join(__dirname, '..');
|
|
const SPECS_FILE = path.join(STATIC_DIR, 'specs.json');
|
|
|
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
|
|
|
|
app.use(express.json({ limit: '10mb' }));
|
|
app.set('trust proxy', 1);
|
|
|
|
// ── Auth middleware ───────────────────────────────────────────────────────────
|
|
function requireAdmin(req, res, next) {
|
|
if (req.headers['x-admin-token'] !== ADMIN_TOKEN) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// ── Health ────────────────────────────────────────────────────────────────────
|
|
app.get('/api/health', (_req, res) => res.json({ status: 'ok', specs: loadSpecs().specs.length }));
|
|
|
|
// ── Get specs ─────────────────────────────────────────────────────────────────
|
|
app.get('/api/specs', (_req, res) => res.json(loadSpecs()));
|
|
|
|
// ── Save specs ────────────────────────────────────────────────────────────────
|
|
app.post('/api/specs', requireAdmin, (req, res) => {
|
|
const data = req.body;
|
|
if (!data || !Array.isArray(data.specs)) return res.status(400).json({ error: 'Invalid payload' });
|
|
try {
|
|
fs.writeFileSync(SPECS_FILE, JSON.stringify(data, null, 2), 'utf8');
|
|
res.json({ ok: true, totalSpecs: data.specs.length });
|
|
} catch (e) {
|
|
console.error('Write error:', e);
|
|
res.status(500).json({ error: 'Failed to save specs' });
|
|
}
|
|
});
|
|
|
|
// ── Parse uploaded file (Excel or CSV) ───────────────────────────────────────
|
|
app.post('/api/import/parse', requireAdmin, upload.single('file'), (req, res) => {
|
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
|
|
|
try {
|
|
const wb = XLSX.read(req.file.buffer, { type: 'buffer' });
|
|
const ws = wb.Sheets[wb.SheetNames[0]];
|
|
const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });
|
|
|
|
// Normalise: try row 0 as headers, if it looks like a title row skip it
|
|
let dataRows = rows;
|
|
if (rows.length && typeof rows[0] === 'object') {
|
|
const firstKey = Object.keys(rows[0])[0] || '';
|
|
// If first row looks like a title (no COUNTRY/RETAILER key), try raw with header row
|
|
if (!firstKey.toUpperCase().includes('COUNTRY') && !firstKey.toUpperCase().includes('RETAILER')) {
|
|
const raw = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
|
|
// Find the header row (first row that contains COUNTRY or RETAILER)
|
|
let headerIdx = 0;
|
|
for (let i = 0; i < Math.min(5, raw.length); i++) {
|
|
const rowStr = raw[i].join(' ').toUpperCase();
|
|
if (rowStr.includes('COUNTRY') || rowStr.includes('RETAILER')) { headerIdx = i; break; }
|
|
}
|
|
const headers = raw[headerIdx].map(h => String(h).trim());
|
|
dataRows = raw.slice(headerIdx + 1)
|
|
.filter(r => r.some(c => c !== ''))
|
|
.map(r => Object.fromEntries(headers.map((h, i) => [h, String(r[i] || '').trim()])));
|
|
}
|
|
}
|
|
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const parsed = dataRows
|
|
.filter(r => getField(r, ['RETAILER', 'retailer', 'Retailer']) ||
|
|
getField(r, ['FORMAT', 'format', 'Format', 'FORMAT NAME', 'format name']))
|
|
.map(r => {
|
|
const fileTypesRaw = getField(r, ['FILE TYPE', 'file type', 'File Type', 'fileType', 'file_type', 'FILE_TYPE']);
|
|
return {
|
|
id: uuidv4(),
|
|
country: getField(r, ['COUNTRY', 'country', 'Country']),
|
|
retailer: getField(r, ['RETAILER', 'retailer', 'Retailer']),
|
|
contentGrouping: getField(r, ['ECOM CONTENT GROUPING', 'Content Grouping', 'contentGrouping', 'content_grouping', 'CONTENT GROUPING', 'category', 'Category']),
|
|
division: getField(r, ['DIVISION', 'division', 'Division']),
|
|
format: getField(r, ['FORMAT', 'format', 'Format', 'FORMAT NAME', 'format name', 'Format Name']),
|
|
dimensions: getField(r, ['ASSET DIMENSION', 'dimensions', 'Dimensions', 'dimension', 'size', 'Size', 'DIMENSIONS']),
|
|
maxWeight: getField(r, ['ASSET WEIGHT', 'maxWeight', 'max weight', 'Max Weight', 'file size', 'File Size', 'MAX WEIGHT']),
|
|
fileTypes: splitFileTypes(fileTypesRaw),
|
|
fileTypesRaw,
|
|
maxAssets: getField(r, ['MAX NUMBER OF ASSETS', 'maxAssets', 'max assets', 'Max Assets', 'MAX ASSETS']),
|
|
guidelines: getField(r, ['RETAILER ASSET GUIDELINES', 'guidelines', 'Guidelines', 'GUIDELINES', 'notes', 'Notes']),
|
|
deliveryMethod: getField(r, ['DELIVERY METHOD FOR SYNDICATION', 'deliveryMethod', 'delivery method', 'Delivery Method']),
|
|
deliveryDetail: getField(r, ['DETAILED', 'deliveryDetail', 'delivery detail', 'Delivery Detail', 'delivery', 'Delivery']),
|
|
handledBy: getField(r, ['HANDLED BY (WIP)', 'handledBy', 'handled by', 'Handled By']),
|
|
namingConvention:getField(r, ['FILE NAMING CONVENTION FOR RETAILER', 'namingConvention', 'naming convention', 'Naming Convention', 'file naming', 'File Naming']),
|
|
uploadLink: getField(r, ['LINKS FOR UPLOAD', 'uploadLink', 'upload link', 'Upload Link']),
|
|
imageExample: getField(r, ['IMAGE EXAMPLE', 'imageExample', 'image example', 'Image Example']),
|
|
notes: getField(r, ['notes', 'Notes', 'NOTES', 'additional notes', 'Additional Notes']),
|
|
updatedAt: today,
|
|
lastVerified: today,
|
|
};
|
|
});
|
|
|
|
res.json({ ok: true, parsed, count: parsed.length });
|
|
} catch (e) {
|
|
console.error('Parse error:', e);
|
|
res.status(500).json({ error: `Failed to parse file: ${e.message}` });
|
|
}
|
|
});
|
|
|
|
// ── Static files ──────────────────────────────────────────────────────────────
|
|
app.use(express.static(STATIC_DIR, { index: 'index.html' }));
|
|
app.get('*', (_req, res) => res.sendFile(path.join(STATIC_DIR, 'index.html')));
|
|
|
|
app.use((err, _req, res, _next) => {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
});
|
|
|
|
app.listen(process.env.PORT || 3101, () => {
|
|
console.log(`L'Oréal Spec Tool running on port ${process.env.PORT || 3101}`);
|
|
});
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
function loadSpecs() {
|
|
try { return JSON.parse(fs.readFileSync(SPECS_FILE, 'utf8')); }
|
|
catch { return { specs: [], meta: {} }; }
|
|
}
|
|
|
|
function getField(row, keys) {
|
|
for (const k of keys) {
|
|
if (row[k] !== undefined && row[k] !== null) {
|
|
const v = String(row[k]).trim();
|
|
if (v && !['NO INFO', 'N/A', '-'].includes(v)) return v;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function splitFileTypes(raw) {
|
|
if (!raw) return [];
|
|
const known = new Set(['JPG','JPEG','PNG','TIFF','TIF','PSD','WEBP','SVG','ZIP','PDF','GIF','MP4','MOV','AVI']);
|
|
return [...new Set(
|
|
raw.toUpperCase().split(/[\s,/|]+/)
|
|
.map(p => p.replace(/[^A-Z]/g, ''))
|
|
.filter(p => known.has(p))
|
|
)];
|
|
}
|