← back to Designer Wallcoverings
security: strip hardcoded dw_admin DSN password -> env-first/passwordless. No rotation/deploy.
b3de17d1518b8b4d2cf58e9789352318e1b0e255 · 2026-05-30 09:49:14 -0700 · Steve
Files touched
D DW-Programming/150dpi/server.js.bakM DW-Programming/ImportNewSkufromURL/.env.exampleM DW-Programming/ImportNewSkufromURL/app/api/auth/login/route.tsD DW-Programming/ImportNewSkufromURL/remove-dwwc-dup-specs-graphql.ts.bakD DW-Programming/ImportNewSkufromURL/remove-dwwc-duplicate-specs.ts.bakD DW-Programming/ImportNewSkufromURL/update-all-schumacher-products.ts.bakM DW-Programming/room-setting-app/app/api/auth/login-password/route.tsM DW-Websites/.gitignoreM DW-Websites/GoldLeafWallpaper/index.htmlM DW-Websites/GrassclothWallcoverings/.gitignoreM DW-Websites/GrassclothWallcoverings/index.htmlM DW-Websites/GrassclothWallcoverings/products-shopify-v4.jsM DW-Websites/GrassclothWallcoverings/styles.cssM DW-Websites/GrassclothWallpaper.com/.gitignoreM DW-Websites/GrassclothWallpaper.com/index.htmlM DW-Websites/GrassclothWallpaper.com/server.jsM DW-Websites/LosAngelesFabricsM DW-Websites/Novasuede/.gitignoreM DW-Websites/Novasuede/index.htmlD DW-Websites/_prod-mirror/product.liquid.bak-pre-design-coordinateM DW-Websites/flockedwallpaper/.gitignoreM DW-Websites/glassbeadedwallpaper/.gitignoreD US-001-IMPLEMENTATION.md.pre-scrub-2026-05-07.bakD ai-enrich-arte-fix.js.pre-scrub-2026-05-07.bakD ai-enrich-gaps2.js.pre-scrub-2026-05-07.bakD arte-fill-tags.js.pre-scrub-2026-05-07.bakD enrich-arte-fix.js.pre-scrub-2026-05-07.bakD enrich-arte-gaps.js.pre-scrub-2026-05-07.bakD enrich-arte-puppeteer.js.pre-scrub-2026-05-07.bakD enrich-remaining-13.js.pre-scrub-2026-05-07.bakD gemini-classify-texture.js.pre-scrub-2026-05-07.bakD gemini-enrich-arte.js.pre-scrub-2026-05-07.bakD gemini-enrich-arte9.js.pre-scrub-2026-05-07.bakD gemini-enrich-gaps.js.pre-scrub-2026-05-07.bakD scripts/ncw-cleanup/run-full-20260506-095239.logD scripts/ncw-cleanup/run-tick2-20260506-100853.log
Diff
commit b3de17d1518b8b4d2cf58e9789352318e1b0e255
Author: Steve <steve@designerwallcoverings.com>
Date: Sat May 30 09:49:14 2026 -0700
security: strip hardcoded dw_admin DSN password -> env-first/passwordless. No rotation/deploy.
---
DW-Programming/150dpi/server.js.bak | 1601 --------------------
DW-Programming/ImportNewSkufromURL/.env.example | 5 +
.../app/api/auth/login/route.ts | 14 +-
.../remove-dwwc-dup-specs-graphql.ts.bak | 173 ---
.../remove-dwwc-duplicate-specs.ts.bak | 193 ---
.../update-all-schumacher-products.ts.bak | 514 -------
.../app/api/auth/login-password/route.ts | 46 +-
DW-Websites/.gitignore | 41 +
DW-Websites/GoldLeafWallpaper/index.html | 81 +-
DW-Websites/GrassclothWallcoverings/.gitignore | 12 +-
DW-Websites/GrassclothWallcoverings/index.html | 1 +
.../GrassclothWallcoverings/products-shopify-v4.js | 139 +-
DW-Websites/GrassclothWallcoverings/styles.css | 56 +-
DW-Websites/GrassclothWallpaper.com/.gitignore | 17 +-
DW-Websites/GrassclothWallpaper.com/index.html | 72 +
DW-Websites/GrassclothWallpaper.com/server.js | 50 +-
DW-Websites/LosAngelesFabrics | 2 +-
DW-Websites/Novasuede/.gitignore | 18 +
DW-Websites/Novasuede/index.html | 1 +
.../product.liquid.bak-pre-design-coordinate | 355 -----
DW-Websites/flockedwallpaper/.gitignore | 15 +
DW-Websites/glassbeadedwallpaper/.gitignore | 15 +
US-001-IMPLEMENTATION.md.pre-scrub-2026-05-07.bak | 194 ---
ai-enrich-arte-fix.js.pre-scrub-2026-05-07.bak | 116 --
ai-enrich-gaps2.js.pre-scrub-2026-05-07.bak | 133 --
arte-fill-tags.js.pre-scrub-2026-05-07.bak | 123 --
enrich-arte-fix.js.pre-scrub-2026-05-07.bak | 139 --
enrich-arte-gaps.js.pre-scrub-2026-05-07.bak | 124 --
enrich-arte-puppeteer.js.pre-scrub-2026-05-07.bak | 164 --
enrich-remaining-13.js.pre-scrub-2026-05-07.bak | 136 --
...ni-classify-texture.js.pre-scrub-2026-05-07.bak | 112 --
gemini-enrich-arte.js.pre-scrub-2026-05-07.bak | 120 --
gemini-enrich-arte9.js.pre-scrub-2026-05-07.bak | 152 --
gemini-enrich-gaps.js.pre-scrub-2026-05-07.bak | 175 ---
scripts/ncw-cleanup/run-full-20260506-095239.log | 77 -
scripts/ncw-cleanup/run-tick2-20260506-100853.log | 48 -
36 files changed, 519 insertions(+), 4715 deletions(-)
diff --git a/DW-Programming/150dpi/server.js.bak b/DW-Programming/150dpi/server.js.bak
deleted file mode 100644
index f4064393..00000000
--- a/DW-Programming/150dpi/server.js.bak
+++ /dev/null
@@ -1,1601 +0,0 @@
-const express = require('express');
-const puppeteer = require('puppeteer');
-const cors = require('cors');
-const path = require('path');
-const fs = require('fs').promises;
-const os = require('os');
-const { exec } = require('child_process');
-const { promisify } = require('util');
-const imageDb = require('./database/db');
-const archiver = require('archiver');
-
-const execPromise = promisify(exec);
-
-const app = express();
-const PORT = 3150;
-const COOKIES_PATH = path.join(os.homedir(), '.spoonflower-cookies.json');
-const DOWNLOAD_PATH = path.join(os.tmpdir(), 'spoonflower-downloads');
-const USER_DATA_DIR = path.join(os.homedir(), '.spoonflower-browser-profile');
-const SERVER_START_TIME = new Date();
-
-// Keep browser instance alive between operations
-let persistentBrowser = null;
-let sessionRestoreAttempted = false;
-
-app.use(cors());
-app.use(express.json({ limit: '50mb' })); // Increase limit for large images
-app.use(express.urlencoded({ limit: '50mb', extended: true }));
-app.use(express.static(__dirname));
-
-// Ensure download directory exists
-async function ensureDownloadDir() {
- try {
- await fs.mkdir(DOWNLOAD_PATH, { recursive: true });
- } catch (error) {
- // Directory already exists
- }
-}
-
-// Load saved cookies
-async function loadCookies() {
- try {
- const cookiesString = await fs.readFile(COOKIES_PATH, 'utf8');
- return JSON.parse(cookiesString);
- } catch (error) {
- return null;
- }
-}
-
-// Save cookies
-async function saveCookies(cookies) {
- await fs.writeFile(COOKIES_PATH, JSON.stringify(cookies, null, 2));
-}
-
-// Detect if user is logged in to Spoonflower
-async function detectLoginSuccess(page) {
- try {
- // Check 1: URL changed away from login page
- const currentUrl = page.url();
- if (!currentUrl.includes('/login') && !currentUrl.includes('/sign-in')) {
- // Check if we're on a logged-in page (not just redirected to home)
- const hasLoggedInIndicators = await page.evaluate(() => {
- // Check for user menu, account links, or logged-in content
- const hasUserMenu = !!document.querySelector('[data-testid="user-menu"], .user-menu, [aria-label*="account" i], [aria-label*="profile" i]');
- const hasAccountLink = !!document.querySelector('a[href*="/account"], a[href*="/profile"], a[href*="/designs"]');
- const hasLogoutButton = !!Array.from(document.querySelectorAll('a, button')).find(el =>
- el.textContent.toLowerCase().includes('log out') || el.textContent.toLowerCase().includes('sign out')
- );
- const hasLoginButton = !!Array.from(document.querySelectorAll('a, button')).find(el =>
- el.textContent.toLowerCase().includes('log in') || el.textContent.toLowerCase().includes('sign in')
- );
-
- // If we have user menu/account link OR logout button, and no login button, we're logged in
- return (hasUserMenu || hasAccountLink || hasLogoutButton) && !hasLoginButton;
- });
-
- if (hasLoggedInIndicators) {
- return true;
- }
- }
-
- // Check 2: Look for logged-in indicators even on login page (in case of redirect back)
- const loggedIn = await page.evaluate(() => {
- // Check for user menu, account links, etc.
- const hasUserMenu = !!document.querySelector('[data-testid="user-menu"], .user-menu, [aria-label*="account" i]');
- const hasAccountLink = !!document.querySelector('a[href*="/account"], a[href*="/profile"]');
- const cookies = document.cookie;
- const hasSessionCookie = cookies.includes('session') || cookies.includes('auth') || cookies.includes('token');
-
- return hasUserMenu || hasAccountLink || hasSessionCookie;
- });
-
- return loggedIn;
- } catch (error) {
- console.error('Error detecting login status:', error);
- return false;
- }
-}
-
-// Clean up browser profile lock
-async function cleanupBrowserProfile() {
- try {
- console.log('Cleaning up browser profile...');
-
- // Kill any Chrome processes using the spoonflower profile
- try {
- await execPromise('pkill -f "spoonflower-browser-profile"');
- console.log('Killed Chrome processes');
- } catch (e) {
- // Process might not exist, that's ok
- console.log('No Chrome processes to kill');
- }
-
- // Remove the lock file
- const lockFile = path.join(USER_DATA_DIR, 'SingletonLock');
- try {
- await fs.unlink(lockFile);
- console.log('Removed SingletonLock file');
- } catch (e) {
- // File might not exist, that's ok
- console.log('No SingletonLock file to remove');
- }
-
- // Wait a moment for processes to fully terminate
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- } catch (error) {
- console.log('Cleanup warning (non-critical):', error.message);
- }
-}
-
-// Attempt to restore browser session from saved profile
-async function restoreBrowserSession() {
- if (sessionRestoreAttempted) {
- console.log('Session restore already attempted');
- return false;
- }
-
- sessionRestoreAttempted = true;
-
- try {
- // Check if we have a saved browser profile
- try {
- await fs.access(USER_DATA_DIR);
- } catch (e) {
- console.log('No saved browser profile found');
- return false;
- }
-
- // Check if we have saved cookies
- const cookies = await loadCookies();
- if (!cookies || cookies.length === 0) {
- console.log('No saved cookies found');
- return false;
- }
-
- console.log(`Found saved profile and ${cookies.length} cookies, attempting to restore session...`);
-
- // Clean up any stale locks before launching
- await cleanupBrowserProfile();
-
- // Launch browser with saved profile
- console.log('Launching browser with saved profile...');
- persistentBrowser = await puppeteer.launch({
- headless: false,
- executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
- userDataDir: USER_DATA_DIR,
- args: [
- '--no-sandbox',
- '--disable-setuid-sandbox',
- '--disable-dev-shm-usage',
- '--disable-blink-features=AutomationControlled',
- '--window-size=1280,900'
- ],
- ignoreHTTPSErrors: true,
- defaultViewport: null
- });
-
- console.log('Browser launched successfully with saved profile');
-
- // Open a page to verify session
- const page = await persistentBrowser.newPage();
- await page.goto('https://www.spoonflower.com/en/artists/designs', {
- waitUntil: 'networkidle2',
- timeout: 30000
- });
-
- // Check if we're actually logged in
- const isLoggedIn = await page.evaluate(() => {
- // Check for common logged-in indicators
- const hasUserMenu = !!document.querySelector('[data-testid="user-menu"], .user-menu, [aria-label*="account" i]');
- const hasLoginButton = !!Array.from(document.querySelectorAll('a, button')).find(el =>
- el.textContent.toLowerCase().includes('log in') || el.textContent.toLowerCase().includes('sign in')
- );
- return hasUserMenu && !hasLoginButton;
- });
-
- if (isLoggedIn) {
- console.log('✓ Browser session restored successfully - user is logged in');
- return true;
- } else {
- console.log('✗ Browser session restored but user is NOT logged in - please run Setup Login');
- // Keep browser open but mark as not logged in
- return false;
- }
-
- } catch (error) {
- console.error('Error restoring browser session:', error.message);
- // Close browser if it was partially opened
- if (persistentBrowser && persistentBrowser.isConnected()) {
- try {
- await persistentBrowser.close();
- } catch (e) {
- // Ignore
- }
- persistentBrowser = null;
- }
- return false;
- }
-}
-
-// Manual Login Setup - Opens browser and automatically detects login
-app.post('/api/manual-login-setup', async (req, res) => {
- let page = null;
- try {
- // Close existing browser if any
- if (persistentBrowser && persistentBrowser.isConnected()) {
- console.log('Closing existing browser session...');
- await persistentBrowser.close();
- persistentBrowser = null;
- }
-
- // Clean up any stale browser processes and locks
- await cleanupBrowserProfile();
-
- console.log('Launching browser for manual login...');
-
- // Launch browser in visible mode
- persistentBrowser = await puppeteer.launch({
- headless: false, // Visible so user can log in
- executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
- userDataDir: USER_DATA_DIR, // Persist login across sessions
- args: [
- '--no-sandbox',
- '--disable-setuid-sandbox',
- '--disable-dev-shm-usage',
- '--disable-blink-features=AutomationControlled',
- '--window-size=1280,900'
- ],
- ignoreHTTPSErrors: true,
- defaultViewport: null
- });
-
- page = await persistentBrowser.newPage();
-
- // Navigate to Spoonflower new design page (which requires login)
- console.log('Opening Spoonflower page...');
- await page.goto('https://www.spoonflower.com/artists/designs/new', {
- waitUntil: 'networkidle2',
- timeout: 30000
- });
-
- // Wait for page to load
- await new Promise(resolve => setTimeout(resolve, 3000));
-
- // Check if we need to log in (page might redirect to login or show login form)
- console.log('Checking if login is required...');
- await new Promise(resolve => setTimeout(resolve, 2000));
-
- const currentUrl = page.url();
- const needsLogin = await page.evaluate(() => {
- // Check if we see login form or login prompts
- const hasLoginForm = !!document.querySelector('form[action*="login"], form[action*="sign-in"]');
- const hasLoginInputs = !!document.querySelector('input[type="email"], input[name="email"]') &&
- !!document.querySelector('input[type="password"]');
- const hasLoginPrompt = !!Array.from(document.querySelectorAll('h1, h2, h3, p')).find(el => {
- const text = el.textContent.toLowerCase();
- return text.includes('log in') || text.includes('sign in') || text.includes('please log in');
- });
-
- return hasLoginForm || (hasLoginInputs && hasLoginPrompt);
- });
-
- // Auto-fill login credentials if login form is present
- if (needsLogin || currentUrl.includes('/login') || currentUrl.includes('/sign-in')) {
- console.log('Login form detected. Auto-filling credentials...');
- try {
- await page.evaluate(() => {
- // Find email input field
- const emailInput = document.querySelector('input[type="email"]') ||
- document.querySelector('input[name="email"]') ||
- document.querySelector('input[id*="email" i]') ||
- document.querySelector('input[placeholder*="email" i]');
-
- // Find password input field
- const passwordInput = document.querySelector('input[type="password"]') ||
- document.querySelector('input[name="password"]') ||
- document.querySelector('input[id*="password" i]');
-
- if (emailInput) {
- emailInput.focus();
- emailInput.value = 'info@designerwallcoverings.com';
- emailInput.dispatchEvent(new Event('input', { bubbles: true }));
- emailInput.dispatchEvent(new Event('change', { bubbles: true }));
- }
-
- if (passwordInput) {
- passwordInput.focus();
- passwordInput.value = '*Spoonaccess911*';
- passwordInput.dispatchEvent(new Event('input', { bubbles: true }));
- passwordInput.dispatchEvent(new Event('change', { bubbles: true }));
- }
- });
-
- // Wait a moment for fields to be filled
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- console.log('Credentials auto-filled! User just needs to solve CAPTCHA (if any) and click login.');
- } catch (fillError) {
- console.log('Could not auto-fill credentials (fields may have different selectors):', fillError.message);
- // Continue anyway - user can still type manually
- }
- } else {
- console.log('Already logged in or on design creation page!');
- }
-
- console.log('Browser opened! Waiting for user to complete login (if needed) or confirm access to design creation page...');
-
- // Monitor for login success with timeout (10 minutes)
- const TIMEOUT = 10 * 60 * 1000; // 10 minutes
- const CHECK_INTERVAL = 2000; // Check every 2 seconds
- const startTime = Date.now();
- let loginDetected = false;
-
- while (Date.now() - startTime < TIMEOUT && !loginDetected) {
- // Check if browser was closed
- if (!persistentBrowser || !persistentBrowser.isConnected()) {
- return res.status(400).json({
- error: 'Browser was closed',
- message: 'Browser window was closed before login completed. Please try again.'
- });
- }
-
- // Check if page is still valid
- try {
- const pages = await persistentBrowser.pages();
- if (pages.length === 0 || !pages.includes(page)) {
- // Page was closed, try to get current page
- const currentPages = await persistentBrowser.pages();
- if (currentPages.length > 0) {
- page = currentPages[currentPages.length - 1];
- } else {
- return res.status(400).json({
- error: 'Browser page closed',
- message: 'Browser page was closed. Please try again.'
- });
- }
- }
-
- // Check for login success
- loginDetected = await detectLoginSuccess(page);
-
- if (loginDetected) {
- console.log('Login detected! Saving cookies...');
-
- // Get cookies from the logged-in session
- const cookies = await page.cookies();
-
- if (cookies.length === 0) {
- // Wait a bit more for cookies to be set
- await new Promise(resolve => setTimeout(resolve, 2000));
- const cookiesRetry = await page.cookies();
- if (cookiesRetry.length === 0) {
- return res.status(400).json({
- error: 'No cookies found',
- message: 'Login detected but no cookies found. Please try logging in again.'
- });
- }
- // Save cookies
- await saveCookies(cookiesRetry);
- console.log(`Saved ${cookiesRetry.length} cookies to file`);
-
- return res.json({
- success: true,
- message: 'Login successful! Cookies saved automatically.',
- cookieCount: cookiesRetry.length
- });
- }
-
- // Save cookies
- await saveCookies(cookies);
- console.log(`Saved ${cookies.length} cookies to file`);
-
- return res.json({
- success: true,
- message: 'Login successful! Cookies saved automatically.',
- cookieCount: cookies.length
- });
- }
- } catch (checkError) {
- console.error('Error checking login status:', checkError);
- // Continue checking unless browser is closed
- if (!persistentBrowser || !persistentBrowser.isConnected()) {
- return res.status(400).json({
- error: 'Browser connection lost',
- message: 'Browser connection was lost. Please try again.'
- });
- }
- }
-
- // Wait before next check
- await new Promise(resolve => setTimeout(resolve, CHECK_INTERVAL));
- }
-
- // Timeout reached
- if (!loginDetected) {
- return res.status(408).json({
- error: 'Login timeout',
- message: 'Login was not completed within 10 minutes. Please try again.'
- });
- }
-
- } catch (error) {
- console.error('Error setting up manual login:', error);
-
- // Clean up browser if error occurred
- if (persistentBrowser && persistentBrowser.isConnected()) {
- try {
- await persistentBrowser.close();
- } catch (e) {
- // Ignore cleanup errors
- }
- persistentBrowser = null;
- }
-
- res.status(500).json({
- error: 'Failed to setup login',
- details: error.message
- });
- }
-});
-
-// Save Login - Call this after user has logged in manually
-app.post('/api/save-login', async (req, res) => {
- try {
- if (!persistentBrowser || !persistentBrowser.isConnected()) {
- return res.status(400).json({
- error: 'No browser session found. Please click "Setup Manual Login" first.'
- });
- }
-
- const pages = await persistentBrowser.pages();
- const page = pages[pages.length - 1];
-
- // Get cookies from the logged-in session
- const cookies = await page.cookies();
-
- if (cookies.length === 0) {
- return res.status(400).json({
- error: 'No cookies found. Please make sure you are logged in to Spoonflower.'
- });
- }
-
- // Save cookies to file
- await saveCookies(cookies);
-
- console.log(`Saved ${cookies.length} cookies to file`);
-
- res.json({
- success: true,
- message: 'Login saved! You can now download and upload designs.',
- cookieCount: cookies.length
- });
-
- } catch (error) {
- console.error('Error saving login:', error);
- res.status(500).json({
- error: 'Failed to save login',
- details: error.message
- });
- }
-});
-
-// Download image from Spoonflower using improved CDP approach
-app.post('/api/download-spoonflower', async (req, res) => {
- const { url } = req.body;
-
- if (!url || !url.includes('spoonflower.com')) {
- return res.status(400).json({ error: 'Invalid Spoonflower URL' });
- }
-
- let browser;
- let page;
- try {
- await ensureDownloadDir();
-
- // Reuse persistent browser if it exists and is connected
- if (persistentBrowser && persistentBrowser.isConnected()) {
- console.log('Reusing existing logged-in browser session');
- browser = persistentBrowser;
-
- // Reuse the SAME PAGE that's already logged in (don't create new page)
- const pages = await browser.pages();
- page = pages[pages.length - 1]; // Use the last page (the one from login)
-
- console.log(`Found ${pages.length} pages, using page ${pages.length - 1}`);
- } else {
- console.log('No active browser session, attempting to restore...');
-
- // Attempt to restore session from saved profile
- const restored = await restoreBrowserSession();
-
- if (restored && persistentBrowser && persistentBrowser.isConnected()) {
- console.log('Session restored successfully, proceeding with download');
- browser = persistentBrowser;
- const pages = await browser.pages();
- page = pages[pages.length - 1];
- } else {
- console.log('Failed to restore session - please run Setup Login first');
- return res.status(401).json({
- error: 'Not logged in',
- needsLogin: true,
- message: 'Please run Setup Login first to establish browser session'
- });
- }
- }
-
- // Set download behavior
- const client = await page.target().createCDPSession();
- await client.send('Page.setDownloadBehavior', {
- behavior: 'allow',
- downloadPath: DOWNLOAD_PATH
- });
-
- // Navigate directly to design page (session from persistent profile)
- console.log('Navigating to design page with persistent session...');
- await page.goto(url, {
- waitUntil: 'networkidle2',
- timeout: 30000
- });
-
- // Wait for page to fully load
- console.log('Waiting for page to load...');
- await new Promise(resolve => setTimeout(resolve, 3000));
-
- // Take a screenshot for debugging
- const screenshotPath = path.join(DOWNLOAD_PATH, 'debug-page.png');
- await page.screenshot({ path: screenshotPath, fullPage: false });
- console.log('Screenshot saved to:', screenshotPath);
-
- // Close any open menus first by clicking elsewhere
- console.log('Closing any open menus...');
- await page.evaluate(() => {
- // Click on the main content area to close menus
- const main = document.querySelector('main') || document.body;
- main.click();
- });
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- // Try to find and click File Options button using multiple strategies
- console.log('Looking for File Options button...');
- const downloadInfo = await page.evaluate(() => {
- // Strategy 1: Find by text content "File Options"
- let button = Array.from(document.querySelectorAll('button')).find(btn =>
- btn.textContent.includes('File Options')
- );
-
- // Strategy 2: Find by text content with more variations
- if (!button) {
- button = Array.from(document.querySelectorAll('button')).find(btn =>
- btn.textContent.match(/file\s*options/i)
- );
- }
-
- // Strategy 3: Find by aria-label
- if (!button) {
- button = document.querySelector('button[aria-label*="File"]') ||
- document.querySelector('button[aria-label*="file"]');
- }
-
- // Strategy 4: Find chakra menu button (common on Spoonflower)
- if (!button) {
- const menuButtons = document.querySelectorAll('button.chakra-menu__menu-button');
- // Try to find one near the design area
- for (let btn of menuButtons) {
- const text = btn.textContent || btn.getAttribute('aria-label') || '';
- if (text.toLowerCase().includes('file') || text.toLowerCase().includes('options')) {
- button = btn;
- break;
- }
- }
- }
-
- // Strategy 5: Find any menu button with 3 dots (⋮) icon
- if (!button) {
- button = Array.from(document.querySelectorAll('button')).find(btn =>
- btn.textContent.includes('⋮') || btn.textContent.includes('•••') || btn.textContent.includes('...')
- );
- }
-
- // Strategy 6: Find button with SVG icon (common pattern)
- if (!button) {
- const buttonsWithSvg = Array.from(document.querySelectorAll('button svg')).map(svg => svg.closest('button'));
- for (let btn of buttonsWithSvg) {
- const ariaLabel = btn?.getAttribute('aria-label') || '';
- if (ariaLabel.toLowerCase().includes('file') || ariaLabel.toLowerCase().includes('menu')) {
- button = btn;
- break;
- }
- }
- }
-
- if (!button) {
- // Debug: List all buttons on page
- const allButtons = Array.from(document.querySelectorAll('button')).map(btn => ({
- text: btn.textContent.substring(0, 50),
- ariaLabel: btn.getAttribute('aria-label'),
- class: btn.className
- }));
- return {
- error: 'File Options button not found',
- debug: allButtons.slice(0, 20) // First 20 buttons for debugging
- };
- }
-
- // Click it
- button.click();
-
- // Wait a moment for menu to appear
- return { clicked: true };
- });
-
- if (downloadInfo.error) {
- console.error('Available buttons on page:', JSON.stringify(downloadInfo.debug, null, 2));
- throw new Error('File Options button not found. You may not be logged in or this may not be your design. Please run "Setup Login" first.');
- }
-
- console.log('File Options clicked, waiting for menu to appear...');
-
- // Wait for the menu to actually appear
- try {
- await page.waitForSelector('[role="menu"], [role="menuitem"], .chakra-menu__menu-list', {
- timeout: 5000,
- visible: true
- });
- console.log('Menu appeared!');
- } catch (err) {
- console.log('Menu selector not found, waiting with delay...');
- }
-
- // Wait longer for menu animation and rendering
- await new Promise(resolve => setTimeout(resolve, 2000));
-
- // Get the download link
- console.log('Finding download link...');
- const downloadUrl = await page.evaluate(() => {
- // Strategy 1: Find by role and download attribute
- let menuItem = document.querySelector('a[role="menuitem"][download]');
-
- // Strategy 2: Find by text content and download attribute
- if (!menuItem) {
- menuItem = Array.from(document.querySelectorAll('a')).find(a =>
- a.textContent.includes('Download') && a.hasAttribute('download')
- );
- }
-
- // Strategy 3: Find by text "Download Current File"
- if (!menuItem) {
- menuItem = Array.from(document.querySelectorAll('a, button')).find(el =>
- el.textContent.includes('Download Current File') ||
- el.textContent.includes('Download File')
- );
- }
-
- // Strategy 4: Find any link/button with "download" in text (case insensitive)
- if (!menuItem) {
- menuItem = Array.from(document.querySelectorAll('a, button')).find(el =>
- el.textContent.toLowerCase().includes('download')
- );
- }
-
- // Strategy 5: Find by href containing download pattern
- if (!menuItem) {
- menuItem = Array.from(document.querySelectorAll('a')).find(a =>
- a.href && (a.href.includes('download') || a.href.includes('.png') || a.href.includes('.jpg'))
- );
- }
-
- if (!menuItem) {
- // Debug: List all menu items (not all page links)
- const menuContainer = document.querySelector('[role="menu"], .chakra-menu__menu-list');
- let menuDebug = [];
-
- if (menuContainer) {
- menuDebug = Array.from(menuContainer.querySelectorAll('a, button')).map(el => ({
- tag: el.tagName,
- text: el.textContent.trim().substring(0, 50),
- role: el.getAttribute('role'),
- download: el.getAttribute('download'),
- href: el.href || null
- }));
- } else {
- // If no menu found, show all links that might be menu items
- menuDebug = Array.from(document.querySelectorAll('[role="menuitem"]')).map(el => ({
- tag: el.tagName,
- text: el.textContent.trim().substring(0, 50),
- role: el.getAttribute('role'),
- download: el.getAttribute('download'),
- href: el.href || null
- }));
- }
-
- return {
- error: 'Download link not found in menu',
- menuFound: !!menuContainer,
- debug: menuDebug.length > 0 ? menuDebug : 'No menu items found'
- };
- }
-
- // If it's a button, we need to click it differently
- const isButton = menuItem.tagName === 'BUTTON';
-
- return {
- url: menuItem.href || 'button',
- filename: menuItem.getAttribute('download') || 'spoonflower_design.png',
- isButton: isButton,
- text: menuItem.textContent
- };
- });
-
- if (downloadUrl.error) {
- console.error('Available menu items:', JSON.stringify(downloadUrl.debug, null, 2));
- throw new Error(downloadUrl.error);
- }
-
- console.log('Download URL found:', downloadUrl.url);
-
- // Click the download link and monitor for file
- console.log('Starting download...');
- await page.evaluate(() => {
- const menuItem = document.querySelector('a[role="menuitem"][download]') ||
- Array.from(document.querySelectorAll('a')).find(a =>
- a.textContent.includes('Download') && a.hasAttribute('download')
- );
- if (menuItem) menuItem.click();
- });
-
- // Wait for file to download
- console.log('Waiting for download to complete...');
- await new Promise(resolve => setTimeout(resolve, 3000));
-
- // Find the downloaded file
- const files = await fs.readdir(DOWNLOAD_PATH);
- const imageFiles = files.filter(f =>
- (f.endsWith('.png') || f.endsWith('.jpg') || f.endsWith('.jpeg')) &&
- f !== 'debug-page.png' // Exclude debug screenshot
- );
-
- if (imageFiles.length === 0) {
- throw new Error('No image file downloaded. Check if download started.');
- }
-
- // Get the most recent file by modification time (not alphabetically)
- const fileStats = await Promise.all(
- imageFiles.map(async (file) => ({
- name: file,
- mtime: (await fs.stat(path.join(DOWNLOAD_PATH, file))).mtime
- }))
- );
- fileStats.sort((a, b) => b.mtime - a.mtime); // Sort newest first
- const downloadedFile = fileStats[0].name;
- const filePath = path.join(DOWNLOAD_PATH, downloadedFile);
-
- console.log('Reading downloaded file:', downloadedFile);
- const imageBuffer = await fs.readFile(filePath);
- const base64Image = imageBuffer.toString('base64');
- const contentType = downloadedFile.endsWith('.png') ? 'image/png' : 'image/jpeg';
-
- // Clean up the downloaded file
- await fs.unlink(filePath);
-
- // Don't close the page - we're reusing it for future downloads
- console.log('Image processed successfully! Browser and page kept open for future downloads.');
-
- res.json({
- success: true,
- image: `data:${contentType};base64,${base64Image}`,
- filename: downloadUrl.filename
- });
-
- } catch (error) {
- console.error('Error downloading from Spoonflower:', error);
- // Don't close the page since we're reusing it
-
- res.status(500).json({
- error: 'Failed to download from Spoonflower',
- details: error.message
- });
- }
-});
-
-// Upload image back to Spoonflower
-app.post('/api/upload-to-spoonflower', async (req, res) => {
- const { url, imageData, proposedName } = req.body;
-
- if (!url || !url.includes('spoonflower.com')) {
- return res.status(400).json({ error: 'Invalid Spoonflower URL' });
- }
-
- if (!imageData) {
- return res.status(400).json({ error: 'No image data provided' });
- }
-
- let browser;
- try {
- // Load saved cookies
- const cookies = await loadCookies();
- if (!cookies) {
- return res.status(401).json({
- error: 'Not logged in',
- needsLogin: true,
- message: 'Please setup Spoonflower login first'
- });
- }
-
- await ensureDownloadDir();
- console.log('Launching browser for upload to:', url);
-
- // Convert base64 image to buffer and save temporarily
- const base64Data = imageData.replace(/^data:image\/\w+;base64,/, '');
- const imageBuffer = Buffer.from(base64Data, 'base64');
- const uploadFilePath = path.join(DOWNLOAD_PATH, 'upload_temp.png');
- await fs.writeFile(uploadFilePath, imageBuffer);
-
- browser = await puppeteer.launch({
- headless: false, // Keep visible so user can see the upload
- executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
- args: [
- '--no-sandbox',
- '--disable-setuid-sandbox',
- '--disable-dev-shm-usage'
- ],
- ignoreHTTPSErrors: true,
- defaultViewport: null
- });
-
- const page = await browser.newPage();
-
- // Set cookies
- await page.setCookie(...cookies);
-
- console.log('Navigating to Spoonflower page...');
- await page.goto(url, {
- waitUntil: 'networkidle2',
- timeout: 30000
- });
-
- // Wait for page to load
- await new Promise(resolve => setTimeout(resolve, 2000));
-
- // STEP 1: Edit and save the title FIRST
- console.log('STEP 1: Editing title to add date...');
- try {
- // Click "Edit Title & Search" accordion button
- const editClicked = await page.evaluate(() => {
- const editBtn = Array.from(document.querySelectorAll('button')).find(btn =>
- btn.textContent.includes('Edit Title') && btn.textContent.includes('Search')
- );
- if (!editBtn) return false;
- editBtn.click();
- return true;
- });
-
- if (editClicked) {
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- // Get current date in format M/D/YY (no zero padding, 2-digit year)
- const today = new Date();
- const month = today.getMonth() + 1; // 1-12
- const day = today.getDate(); // 1-31
- const year = String(today.getFullYear()).slice(-2); // Last 2 digits
- const dateStr = `${month}/${day}/${year}`;
-
- // Edit the title by actually typing into the field
- const currentTitle = await page.evaluate(() => {
- const titleInput = document.querySelector('input[name="title"]');
- return titleInput ? titleInput.value : null;
- });
-
- if (!currentTitle) {
- console.log('Could not find title input');
- } else {
- // If proposed name is provided, use it; otherwise use suffix
- if (proposedName) {
- console.log(`Current title: "${currentTitle}"`);
- console.log(`Using proposed name: "${proposedName}"`);
-
- // Clear and set new title
- await page.click('input[name="title"]');
- await new Promise(resolve => setTimeout(resolve, 300));
-
- // Select all and delete
- await page.evaluate(() => {
- const titleInput = document.querySelector('input[name="title"]');
- titleInput.select();
- });
- await page.keyboard.press('Backspace');
- await new Promise(resolve => setTimeout(resolve, 200));
-
- // Type the new proposed name
- await page.type('input[name="title"]', proposedName, { delay: 50 });
-
- console.log('Set new title to proposed name');
- await new Promise(resolve => setTimeout(resolve, 500));
- } else {
- const suffix = ` 24in-150dpi Updated ${dateStr} `;
-
- // Only append if not already there
- if (!currentTitle.includes('24in-150dpi') && !currentTitle.includes(suffix.trim())) {
- console.log(`Current title: "${currentTitle}"`);
- console.log(`Adding suffix: "${suffix}"`);
-
- // Click on the title input to focus it
- await page.click('input[name="title"]');
- await new Promise(resolve => setTimeout(resolve, 300));
-
- // Move cursor to end of input
- await page.evaluate(() => {
- const titleInput = document.querySelector('input[name="title"]');
- titleInput.selectionStart = titleInput.value.length;
- titleInput.selectionEnd = titleInput.value.length;
- });
-
- // Type the suffix character by character
- await page.type('input[name="title"]', suffix, { delay: 50 });
-
- console.log('Typed suffix into title field');
- await new Promise(resolve => setTimeout(resolve, 500));
- }
- }
- }
-
- const titleUpdated = true;
-
- if (titleUpdated) {
- // Wait for form interaction to settle
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- // Click Save button with better detection
- console.log('Looking for Save button...');
- const saveClicked = await page.evaluate(() => {
- // Try multiple strategies to find the Save button
- let saveBtn = document.querySelector('button[type="submit"].chakra-button');
-
- if (!saveBtn) {
- saveBtn = Array.from(document.querySelectorAll('button[type="submit"]')).find(btn =>
- btn.textContent.trim() === 'Save'
- );
- }
-
- if (!saveBtn) {
- saveBtn = Array.from(document.querySelectorAll('button')).find(btn =>
- btn.textContent.includes('Save') && btn.type === 'submit'
- );
- }
-
- if (saveBtn) {
- console.log('Found Save button, clicking...');
- saveBtn.click();
- return true;
- }
- return false;
- });
-
- if (saveClicked) {
- console.log('Save button clicked! Waiting for save to complete...');
- await new Promise(resolve => setTimeout(resolve, 5000));
- console.log('Title saved successfully!');
- } else {
- console.log('Warning: Save button not found - title may not be saved');
- }
- }
- }
- } catch (titleError) {
- console.log('Warning: Could not update title:', titleError.message);
- // Continue with image upload even if title update fails
- }
-
- // STEP 2: Now replace the image
- console.log('STEP 2: Replacing image file...');
-
- // Click File Options button
- console.log('Looking for File Options button...');
- const fileOptionsClicked = await page.evaluate(() => {
- let button = Array.from(document.querySelectorAll('button')).find(btn =>
- btn.textContent.includes('File Options')
- );
- if (!button) {
- button = document.querySelector('button.chakra-menu__menu-button');
- }
- if (!button) return false;
- button.click();
- return true;
- });
-
- if (!fileOptionsClicked) {
- throw new Error('File Options button not found');
- }
-
- console.log('Waiting for menu...');
- await new Promise(resolve => setTimeout(resolve, 1500));
-
- // Click "Replace Current File"
- console.log('Clicking Replace Current File...');
- const replaceClicked = await page.evaluate(() => {
- const replaceBtn = Array.from(document.querySelectorAll('button')).find(btn =>
- btn.textContent.includes('Replace Current File')
- );
- if (!replaceBtn) return false;
- replaceBtn.click();
- return true;
- });
-
- if (!replaceClicked) {
- throw new Error('Replace Current File button not found');
- }
-
- // Wait for confirmation dialog
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- // Click "Yes, continue" on confirmation dialog
- console.log('Confirming replacement...');
- await page.evaluate(() => {
- const yesButton = Array.from(document.querySelectorAll('button')).find(btn =>
- btn.textContent.includes('Yes, continue') || btn.textContent.includes('continue')
- );
- if (yesButton) yesButton.click();
- });
-
- // Wait for file input to appear
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- // Find and upload file
- console.log('Uploading file...');
- const fileInput = await page.$('input[type="file"]');
- if (!fileInput) {
- throw new Error('File input not found');
- }
-
- await fileInput.uploadFile(uploadFilePath);
-
- // Wait for upload to process
- console.log('Upload complete! Waiting for processing...');
- await new Promise(resolve => setTimeout(resolve, 3000));
-
- // Clean up temp file
- await fs.unlink(uploadFilePath);
-
- console.log('Upload successful! Browser kept open for confirmation.');
-
- res.json({
- success: true,
- message: 'Image uploaded to Spoonflower and title updated! Check the browser window.',
- keepBrowserOpen: true
- });
-
- // Note: Browser stays open so user can see the result
- // They can close it manually
-
- } catch (error) {
- console.error('Error uploading to Spoonflower:', error);
-
- try {
- if (browser) await browser.close();
- } catch (closeError) {
- console.log('Browser already closed');
- }
-
- res.status(500).json({
- error: 'Failed to upload to Spoonflower',
- details: error.message
- });
- }
-});
-
-// Check login status by checking browser session and cookies
-app.get('/api/check-login', async (req, res) => {
- try {
- // First check if we have an active browser session
- if (persistentBrowser && persistentBrowser.isConnected()) {
- return res.json({
- loggedIn: true,
- browserActive: true,
- message: 'Browser session active'
- });
- }
-
- // Check if we have saved cookies and profile
- const cookies = await loadCookies();
- let hasProfile = false;
- try {
- await fs.access(USER_DATA_DIR);
- hasProfile = true;
- } catch (e) {
- // No profile
- }
-
- if (!cookies || cookies.length === 0) {
- return res.json({
- loggedIn: false,
- browserActive: false,
- hasProfile: hasProfile,
- message: 'Not logged in - please run Setup Login'
- });
- }
-
- // Check if cookies are still valid (not expired)
- const now = Date.now() / 1000;
- const validCookies = cookies.filter(cookie => {
- // Session cookies (no expires field) are always valid until browser closes
- if (!cookie.expires) return true;
- // Check if cookie hasn't expired
- return cookie.expires > now;
- });
-
- const canRestore = validCookies.length > 0 && hasProfile;
-
- res.json({
- loggedIn: canRestore,
- browserActive: false,
- canRestore: canRestore,
- cookieCount: validCookies.length,
- hasProfile: hasProfile,
- message: canRestore ?
- 'Session can be restored (browser will auto-launch on next operation)' :
- 'Session expired - please run Setup Login again'
- });
- } catch (error) {
- res.json({
- loggedIn: false,
- browserActive: false,
- message: 'Not logged in'
- });
- }
-});
-
-// Server info
-app.get('/api/server-info', (req, res) => {
- res.json({
- startTime: SERVER_START_TIME.toISOString(),
- uptime: process.uptime()
- });
-});
-
-// ===== IMAGE DATABASE API =====
-
-// GET all images (with optional status filter)
-app.get('/api/images', (req, res) => {
- try {
- const status = req.query.status || 'active';
- const images = imageDb.getAllImages(status);
- res.json(images);
- } catch (error) {
- console.error('Error fetching images:', error);
- res.status(500).json({ error: 'Failed to fetch images', details: error.message });
- }
-});
-
-// GET single image by ID
-app.get('/api/images/:id', (req, res) => {
- try {
- const image = imageDb.getImageById(parseInt(req.params.id));
- if (!image) {
- return res.status(404).json({ error: 'Image not found' });
- }
- res.json(image);
- } catch (error) {
- console.error('Error fetching image:', error);
- res.status(500).json({ error: 'Failed to fetch image', details: error.message });
- }
-});
-
-// POST create new image
-app.post('/api/images', (req, res) => {
- try {
- const {
- original_filename,
- original_path,
- converted_path,
- width,
- height,
- tags,
- extracted_colors,
- original_image_data,
- converted_image_data,
- conversion_settings,
- spoonflower_url
- } = req.body;
- if (!original_filename) {
- return res.status(400).json({ error: 'original_filename is required' });
- }
- const image = imageDb.createImage({
- original_filename,
- original_path,
- converted_path,
- width,
- height,
- tags: tags || [],
- extracted_colors: extracted_colors || [],
- original_image_data: original_image_data || null,
- converted_image_data: converted_image_data || null,
- conversion_settings: conversion_settings || null,
- spoonflower_url: spoonflower_url || null
- });
- res.status(201).json(image);
- } catch (error) {
- console.error('Error creating image:', error);
- res.status(500).json({ error: 'Failed to create image', details: error.message });
- }
-});
-
-// PUT update image (including tags)
-app.put('/api/images/:id', (req, res) => {
- try {
- const id = parseInt(req.params.id);
- const existing = imageDb.getImageById(id);
- if (!existing) {
- return res.status(404).json({ error: 'Image not found' });
- }
- const updated = imageDb.updateImage(id, req.body);
- res.json(updated);
- } catch (error) {
- console.error('Error updating image:', error);
- res.status(500).json({ error: 'Failed to update image', details: error.message });
- }
-});
-
-// DELETE image
-app.delete('/api/images/:id', (req, res) => {
- try {
- const id = parseInt(req.params.id);
- const existing = imageDb.getImageById(id);
- if (!existing) {
- return res.status(404).json({ error: 'Image not found' });
- }
- imageDb.deleteImage(id);
- res.json({ success: true, message: 'Image deleted' });
- } catch (error) {
- console.error('Error deleting image:', error);
- res.status(500).json({ error: 'Failed to delete image', details: error.message });
- }
-});
-
-// PATCH archive image
-app.patch('/api/images/:id/archive', (req, res) => {
- try {
- const id = parseInt(req.params.id);
- const existing = imageDb.getImageById(id);
- if (!existing) {
- return res.status(404).json({ error: 'Image not found' });
- }
- const updated = imageDb.archiveImage(id);
- res.json(updated);
- } catch (error) {
- console.error('Error archiving image:', error);
- res.status(500).json({ error: 'Failed to archive image', details: error.message });
- }
-});
-
-// PATCH restore image
-app.patch('/api/images/:id/restore', (req, res) => {
- try {
- const id = parseInt(req.params.id);
- const existing = imageDb.getImageById(id);
- if (!existing) {
- return res.status(404).json({ error: 'Image not found' });
- }
- const updated = imageDb.restoreImage(id);
- res.json(updated);
- } catch (error) {
- console.error('Error restoring image:', error);
- res.status(500).json({ error: 'Failed to restore image', details: error.message });
- }
-});
-
-// GET all unique tags with counts
-app.get('/api/tags', (req, res) => {
- try {
- const tags = imageDb.getAllTags();
- res.json(tags);
- } catch (error) {
- console.error('Error fetching tags:', error);
- res.status(500).json({ error: 'Failed to fetch tags', details: error.message });
- }
-});
-
-// POST export selected images as ZIP
-app.post('/api/images/export-zip', (req, res) => {
- try {
- const { imageIds, includeOriginal = true, includeConverted = true } = req.body;
-
- if (!imageIds || !Array.isArray(imageIds) || imageIds.length === 0) {
- return res.status(400).json({ error: 'imageIds array is required' });
- }
-
- // Create timestamp for filename
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
- const zipFilename = `150dpi-export-${timestamp}.zip`;
-
- // Set response headers
- res.setHeader('Content-Type', 'application/zip');
- res.setHeader('Content-Disposition', `attachment; filename="${zipFilename}"`);
-
- // Create archiver instance
- const archive = archiver('zip', {
- zlib: { level: 9 } // Maximum compression
- });
-
- // Handle archiver warnings and errors
- archive.on('warning', (err) => {
- if (err.code === 'ENOENT') {
- console.warn('Archiver warning:', err);
- } else {
- throw err;
- }
- });
-
- archive.on('error', (err) => {
- console.error('Archiver error:', err);
- res.status(500).json({ error: 'Error creating ZIP', details: err.message });
- });
-
- // Pipe archive to response
- archive.pipe(res);
-
- // Add images to ZIP
- let filesAdded = 0;
- for (const imageId of imageIds) {
- const image = imageDb.getImageById(parseInt(imageId));
- if (!image) {
- console.warn(`Image ${imageId} not found, skipping`);
- continue;
- }
-
- const baseName = image.original_filename.replace(/\.[^/.]+$/, ''); // Remove extension
-
- if (includeOriginal && image.original_image_data) {
- const buffer = Buffer.from(image.original_image_data.replace(/^data:image\/\w+;base64,/, ''), 'base64');
- archive.append(buffer, { name: `original/${image.original_filename}` });
- filesAdded++;
- }
-
- if (includeConverted && image.converted_image_data) {
- const buffer = Buffer.from(image.converted_image_data.replace(/^data:image\/\w+;base64,/, ''), 'base64');
- const ext = image.converted_image_data.match(/^data:image\/(\w+);base64,/)?.[1] || 'png';
- archive.append(buffer, { name: `converted/${baseName}-150dpi.${ext}` });
- filesAdded++;
- }
- }
-
- if (filesAdded === 0) {
- return res.status(404).json({ error: 'No images found to export' });
- }
-
- // Finalize the archive
- archive.finalize();
-
- } catch (error) {
- console.error('Error exporting ZIP:', error);
- res.status(500).json({ error: 'Failed to export ZIP', details: error.message });
- }
-});
-
-// Upload NEW design to Spoonflower (not updating existing)
-app.post('/api/upload-new-design', async (req, res) => {
- const { imageData, proposedName } = req.body;
-
- if (!imageData) {
- return res.status(400).json({ error: 'No image data provided' });
- }
-
- try {
- // Reuse persistent browser if it exists and is connected
- if (!persistentBrowser || !persistentBrowser.isConnected()) {
- console.log('No active browser session, attempting to restore...');
-
- // Attempt to restore session from saved profile
- const restored = await restoreBrowserSession();
-
- if (!restored || !persistentBrowser || !persistentBrowser.isConnected()) {
- console.log('Failed to restore session - please run Setup Login first');
- return res.status(401).json({
- error: 'Not logged in',
- needsLogin: true,
- message: 'Please run Setup Login first to establish browser session'
- });
- }
- }
-
- console.log('Using existing logged-in browser session for new design upload');
- const browser = persistentBrowser;
-
- // Reuse existing page or create new one
- const pages = await browser.pages();
- const page = pages[pages.length - 1];
-
- // Convert base64 to buffer and save temporarily
- const base64Data = imageData.replace(/^data:image\/\w+;base64,/, '');
- const imageBuffer = Buffer.from(base64Data, 'base64');
-
- await ensureDownloadDir();
- const uploadFilePath = path.join(DOWNLOAD_PATH, 'upload_new_design.png');
- await fs.writeFile(uploadFilePath, imageBuffer);
- console.log('Saved image to:', uploadFilePath);
-
- // Navigate to new design upload page
- console.log('Navigating to /artists/designs/new...');
- await page.goto('https://www.spoonflower.com/en/artists/designs/new', {
- waitUntil: 'networkidle2',
- timeout: 30000
- });
-
- // Wait for page to load
- await new Promise(resolve => setTimeout(resolve, 2000));
-
- // Click the upload button to show the file input
- console.log('Checking for upload button...');
- const uploadButtonClicked = await page.evaluate(() => {
- const uploadBtn = Array.from(document.querySelectorAll('button')).find(btn =>
- btn.textContent.includes('Upload') || btn.textContent.includes('Choose')
- );
- if (uploadBtn) {
- uploadBtn.click();
- return true;
- }
- return false;
- });
-
- if (uploadButtonClicked) {
- console.log('Clicked upload button, waiting for form...');
- await new Promise(resolve => setTimeout(resolve, 2000));
- }
-
- // Find and use the file input
- console.log('Looking for file input...');
- const fileInput = await page.$('input[type="file"]');
-
- if (!fileInput) {
- console.log('File input not found immediately, waiting...');
- await new Promise(resolve => setTimeout(resolve, 2000));
-
- // Try clicking on the upload area
- console.log('Trying to click on upload area...');
- await page.evaluate(() => {
- const uploadArea = document.querySelector('[data-testid="upload-area"], .upload-zone, .chakra-button');
- if (uploadArea) uploadArea.click();
- });
- await new Promise(resolve => setTimeout(resolve, 1000));
- }
-
- const retryFileInput = await page.$('input[type="file"]');
- if (!retryFileInput) {
- throw new Error('File input not found on upload page. Please check the browser window and upload manually if needed.');
- }
-
- // Upload the file
- console.log('Found file input, uploading file...');
- await retryFileInput.uploadFile(uploadFilePath);
- console.log('File uploaded! Waiting for processing...');
-
- // Wait for upload to process
- await new Promise(resolve => setTimeout(resolve, 4000));
-
- // CRITICAL: Handle Terms of Service modal if it appears
- console.log('Checking for Terms of Service modal...');
- const tosAccepted = await page.evaluate(() => {
- // Look for the "Agree & Continue" button in the TOS modal
- const agreeButton = Array.from(document.querySelectorAll('button')).find(btn =>
- btn.textContent.includes('Agree') && btn.textContent.includes('Continue')
- );
-
- if (agreeButton) {
- console.log('Found "Agree & Continue" button, clicking...');
- agreeButton.click();
- return true;
- }
- return false;
- });
-
- if (tosAccepted) {
- console.log('Clicked "Agree & Continue" button');
- await new Promise(resolve => setTimeout(resolve, 3000));
- } else {
- console.log('No Terms of Service modal found (may have been previously accepted)');
- }
-
- // Set proposed name if provided
- if (proposedName) {
- console.log(`Setting proposed name: ${proposedName}`);
- await page.evaluate((name) => {
- const titleInput = document.querySelector('input[name="title"], input[placeholder*="title" i]');
- if (titleInput) {
- titleInput.value = name;
- titleInput.dispatchEvent(new Event('input', { bubbles: true }));
- }
- }, proposedName);
- await new Promise(resolve => setTimeout(resolve, 500));
- }
-
- // Wait for form to be ready
- console.log('Waiting for upload to process and form to be ready...');
- await new Promise(resolve => setTimeout(resolve, 3000));
-
- // Check for required form fields and fill if needed
- console.log('Checking for required form fields...');
- await page.evaluate(() => {
- // Fill any required fields with default values if empty
- const requiredInputs = document.querySelectorAll('input[required], select[required]');
- requiredInputs.forEach(input => {
- if (!input.value && input.type !== 'file') {
- if (input.type === 'checkbox') {
- input.checked = true;
- } else {
- input.value = 'Default';
- }
- }
- });
- });
-
- // Find and click Submit/Save button
- console.log('Looking for Submit/Save button...');
- const submitClicked = await page.evaluate(() => {
- let submitBtn = document.querySelector('button[type="submit"]');
-
- if (!submitBtn) {
- submitBtn = Array.from(document.querySelectorAll('button')).find(btn =>
- btn.textContent.includes('Submit') ||
- btn.textContent.includes('Save') ||
- btn.textContent.includes('Create')
- );
- }
-
- if (submitBtn) {
- submitBtn.click();
- return true;
- }
- return false;
- });
-
- if (!submitClicked) {
- throw new Error('Submit button not found');
- }
-
- console.log('Submit button clicked! Waiting for design creation...');
- await new Promise(resolve => setTimeout(resolve, 5000));
-
- // Get the new design URL
- const currentUrl = page.url();
- console.log('Current URL after submit:', currentUrl);
-
- res.json({
- success: true,
- message: 'Design uploaded successfully',
- url: currentUrl
- });
-
- } catch (error) {
- console.error('Error uploading new design:', error);
- res.status(500).json({
- error: 'Failed to upload new design',
- details: error.message
- });
- }
-});
-
-// Health check
-app.get('/api/health', (req, res) => {
- res.json({ status: 'ok', message: '150 DPI Converter API is running' });
-});
-
-// Startup cleanup to remove stale browser locks
-async function startupCleanup() {
- console.log('Startup: Cleaning up any stale browser locks...');
- await cleanupBrowserProfile();
- console.log('Startup: Ready for operations');
-}
-app.listen(PORT, async () => {
- console.log(`
- await startupCleanup();
-╔════════════════════════════════════════════╗
-║ 150 DPI Converter Server Running ║
-║ ║
-║ URL: http://localhost:${PORT} ║
-║ API: http://localhost:${PORT}/api ║
-╚════════════════════════════════════════════╝
- `);
-
- // Clean up any stale browser profile locks on startup
- console.log('\nStartup: Cleaning up any stale browser locks...');
- await cleanupBrowserProfile();
- console.log('Startup: Ready for operations\n');
-});
diff --git a/DW-Programming/ImportNewSkufromURL/.env.example b/DW-Programming/ImportNewSkufromURL/.env.example
index eda65df1..c8a099f9 100644
--- a/DW-Programming/ImportNewSkufromURL/.env.example
+++ b/DW-Programming/ImportNewSkufromURL/.env.example
@@ -1,3 +1,8 @@
+# Basic-auth credentials for /api/auth/login in "user:password" form.
+# REQUIRED at boot — server throws if missing. MUST be set in prod .env to
+# a freshly-rotated secret (do NOT reuse the legacy DW2025Secure! value).
+DW_BASIC_AUTH=admin:CHANGE-ME-BEFORE-DEPLOY
+
# Hugging Face API Key (optional - for higher rate limits)
# Get yours at https://huggingface.co/settings/tokens
HUGGINGFACE_API_KEY=
diff --git a/DW-Programming/ImportNewSkufromURL/app/api/auth/login/route.ts b/DW-Programming/ImportNewSkufromURL/app/api/auth/login/route.ts
index 6a8c063f..b248a6fb 100644
--- a/DW-Programming/ImportNewSkufromURL/app/api/auth/login/route.ts
+++ b/DW-Programming/ImportNewSkufromURL/app/api/auth/login/route.ts
@@ -1,7 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
-const USERNAME = 'admin';
-const PASSWORD = 'DW2025Secure!';
+// Credentials must be supplied via DW_BASIC_AUTH in "user:password" form.
+// Fail-closed at module load — never default to a hardcoded literal.
+const DW_BASIC_AUTH = process.env.DW_BASIC_AUTH;
+if (!DW_BASIC_AUTH) {
+ throw new Error('DW_BASIC_AUTH env var required for auth — set in .env before boot');
+}
+const colonIdx = DW_BASIC_AUTH.indexOf(':');
+if (colonIdx <= 0 || colonIdx === DW_BASIC_AUTH.length - 1) {
+ throw new Error('DW_BASIC_AUTH must be in "user:password" form');
+}
+const USERNAME = DW_BASIC_AUTH.slice(0, colonIdx);
+const PASSWORD = DW_BASIC_AUTH.slice(colonIdx + 1);
export async function POST(request: NextRequest) {
try {
diff --git a/DW-Programming/ImportNewSkufromURL/remove-dwwc-dup-specs-graphql.ts.bak b/DW-Programming/ImportNewSkufromURL/remove-dwwc-dup-specs-graphql.ts.bak
deleted file mode 100644
index 5976958e..00000000
--- a/DW-Programming/ImportNewSkufromURL/remove-dwwc-dup-specs-graphql.ts.bak
+++ /dev/null
@@ -1,173 +0,0 @@
-/**
- * Remove duplicate "Specifications" sections from DWWC products using GraphQL
- */
-
-import { shopifyGraphQLRequest } from './lib/shopify';
-
-interface Product {
- id: string;
- title: string;
- description: string;
- handle: string;
-}
-
-function cleanDuplicateSpecs(bodyHtml: string): { cleaned: string; removed: boolean } {
- let cleaned = bodyHtml;
- let removed = false;
-
- // Remove duplicate <h3>Specifications</h3> sections - keep only FIRST occurrence
- const specsMatches = cleaned.match(/<h3>Specifications<\/h3>/gi);
- if (specsMatches && specsMatches.length > 1) {
- console.log(` Found ${specsMatches.length} "Specifications" headers - keeping only first`);
-
- const firstIndex = cleaned.indexOf('<h3>Specifications</h3>');
- let tempCleaned = cleaned.substring(0, firstIndex);
- let remaining = cleaned.substring(firstIndex);
-
- // Keep the first specs section
- const firstSpecsEnd = remaining.indexOf('</ul>') + 5;
- tempCleaned += remaining.substring(0, firstSpecsEnd);
- remaining = remaining.substring(firstSpecsEnd);
-
- // Remove all subsequent specs sections
- remaining = remaining.replace(/<h3>Specifications<\/h3>\s*<ul>[\s\S]*?<\/ul>/gi, '');
-
- cleaned = tempCleaned + remaining;
- removed = true;
- }
-
- // Remove <h3>Product Information</h3> sections (redundant)
- if (cleaned.includes('<h3>Product Information</h3>') || cleaned.includes('<h4>Product Information</h4>')) {
- console.log(` Removing "Product Information" section`);
- cleaned = cleaned.replace(/<h[34]>Product\s+Information<\/h[34]>\s*<ul>[\s\S]*?<\/ul>/gi, '');
- removed = true;
- }
-
- // Clean up extra whitespace
- cleaned = cleaned.replace(/\n\s*\n\s*\n/g, '\n\n').trim();
-
- return { cleaned, removed };
-}
-
-async function getAllDWWCProducts(): Promise<Product[]> {
- console.log('📦 Fetching all DWWC products via GraphQL...\n');
-
- const allProducts: Product[] = [];
- let hasNextPage = true;
- let cursor: string | null = null;
-
- while (hasNextPage) {
- const query = `
- query GetProducts($cursor: String) {
- products(first: 50, after: $cursor, query: "vendor:'Designer Wallcoverings'") {
- edges {
- node {
- id
- title
- description
- handle
- }
- cursor
- }
- pageInfo {
- hasNextPage
- }
- }
- }
- `;
-
- const response = await shopifyGraphQLRequest(query, { cursor });
-
- const edges = response.data.products.edges;
- for (const edge of edges) {
- allProducts.push(edge.node);
- }
-
- hasNextPage = response.data.products.pageInfo.hasNextPage;
- cursor = edges.length > 0 ? edges[edges.length - 1].cursor : null;
-
- console.log(` Fetched ${allProducts.length} products so far...`);
- }
-
- console.log(`\n✅ Total products fetched: ${allProducts.length}\n`);
- return allProducts;
-}
-
-async function updateProductDescription(productId: string, newDescription: string): Promise<void> {
- const mutation = `
- mutation UpdateProduct($input: ProductInput!) {
- productUpdate(input: $input) {
- product {
- id
- description
- }
- userErrors {
- field
- message
- }
- }
- }
- `;
-
- const response = await shopifyGraphQLRequest(mutation, {
- input: {
- id: productId,
- description: newDescription,
- },
- });
-
- if (response.data.productUpdate.userErrors.length > 0) {
- throw new Error(JSON.stringify(response.data.productUpdate.userErrors));
- }
-}
-
-async function main() {
- console.log('🧹 DWWC Description Cleanup - Remove Duplicate Specifications\n');
- console.log('='.repeat(80) + '\n');
-
- const products = await getAllDWWCProducts();
-
- let processedCount = 0;
- let updatedCount = 0;
- let errorCount = 0;
-
- for (const product of products) {
- processedCount++;
-
- console.log(`[${processedCount}/${products.length}] ${product.title}`);
-
- if (!product.description) {
- console.log(' ⏭️ Skipping - no description\n');
- continue;
- }
-
- const { cleaned, removed } = cleanDuplicateSpecs(product.description);
-
- if (!removed) {
- console.log(' ✓ No duplicates found\n');
- continue;
- }
-
- try {
- await updateProductDescription(product.id, cleaned);
- updatedCount++;
- console.log(' ✅ Updated successfully\n');
- } catch (error) {
- errorCount++;
- console.error(` ❌ Error: ${error}\n`);
- }
-
- // Rate limiting: 2 requests per second
- await new Promise(resolve => setTimeout(resolve, 500));
- }
-
- console.log('='.repeat(80));
- console.log('\n📊 FINAL SUMMARY:');
- console.log(` Total products: ${products.length}`);
- console.log(` Updated: ${updatedCount}`);
- console.log(` Errors: ${errorCount}`);
- console.log(` No changes needed: ${products.length - updatedCount - errorCount}`);
- console.log('\n✅ Cleanup complete!');
-}
-
-main().catch(console.error);
diff --git a/DW-Programming/ImportNewSkufromURL/remove-dwwc-duplicate-specs.ts.bak b/DW-Programming/ImportNewSkufromURL/remove-dwwc-duplicate-specs.ts.bak
deleted file mode 100644
index 02f21a8e..00000000
--- a/DW-Programming/ImportNewSkufromURL/remove-dwwc-duplicate-specs.ts.bak
+++ /dev/null
@@ -1,193 +0,0 @@
-/**
- * Remove duplicate "Specifications" or "Product Information" sections
- * from DWWC product descriptions
- */
-
-import 'dotenv/config';
-
-const SHOPIFY_STORE = process.env.SHOPIFY_STORE_DOMAIN!;
-const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!;
-const SHOPIFY_API_VERSION = process.env.SHOPIFY_ADMIN_API_VERSION || '2024-07';
-
-interface ShopifyProduct {
- id: string;
- title: string;
- body_html: string;
- vendor: string;
- handle: string;
-}
-
-async function getAllDWWCProducts(): Promise<ShopifyProduct[]> {
- console.log('📦 Fetching all DWWC products...\n');
-
- const allProducts: ShopifyProduct[] = [];
- let hasNextPage = true;
- let pageInfo: string | null = null;
-
- while (hasNextPage) {
- const url = pageInfo
- ? `https://${SHOPIFY_STORE}/admin/api/${SHOPIFY_API_VERSION}/products.json?vendor=Designer%20Wallcoverings&limit=250&page_info=${pageInfo}`
- : `https://${SHOPIFY_STORE}/admin/api/${SHOPIFY_API_VERSION}/products.json?vendor=Designer%20Wallcoverings&limit=250`;
-
- const response = await fetch(url, {
- headers: {
- 'X-Shopify-Access-Token': SHOPIFY_TOKEN,
- },
- });
-
- if (!response.ok) {
- throw new Error(`Failed to fetch products: ${response.statusText}`);
- }
-
- const data = await response.json();
- allProducts.push(...data.products);
-
- // Check for pagination
- const linkHeader = response.headers.get('Link');
- if (linkHeader && linkHeader.includes('rel="next"')) {
- const match = linkHeader.match(/page_info=([^>]+)>; rel="next"/);
- pageInfo = match ? match[1] : null;
- hasNextPage = !!pageInfo;
- } else {
- hasNextPage = false;
- }
-
- console.log(` Fetched ${allProducts.length} products so far...`);
- }
-
- console.log(`\n✅ Total products fetched: ${allProducts.length}\n`);
- return allProducts;
-}
-
-function cleanDuplicateSpecs(bodyHtml: string): { cleaned: string; removed: boolean } {
- let cleaned = bodyHtml;
- let removed = false;
-
- // Pattern 1: Remove duplicate <h3>Specifications</h3> sections
- // Keep only the FIRST occurrence
- const specsMatches = cleaned.match(/<h3>Specifications<\/h3>/gi);
- if (specsMatches && specsMatches.length > 1) {
- console.log(` Found ${specsMatches.length} "Specifications" headers - removing duplicates`);
-
- // Find the first occurrence
- const firstIndex = cleaned.indexOf('<h3>Specifications</h3>');
-
- // Remove all subsequent occurrences along with their content
- let tempCleaned = cleaned.substring(0, firstIndex);
- let remaining = cleaned.substring(firstIndex);
-
- // Keep the first specs section
- const firstSpecsEnd = remaining.indexOf('</ul>') + 5;
- tempCleaned += remaining.substring(0, firstSpecsEnd);
- remaining = remaining.substring(firstSpecsEnd);
-
- // Remove any subsequent specs sections
- remaining = remaining.replace(/<h3>Specifications<\/h3>\s*<ul>[\s\S]*?<\/ul>/gi, '');
-
- cleaned = tempCleaned + remaining;
- removed = true;
- }
-
- // Pattern 2: Remove <h3>Product Information</h3> sections entirely
- // (these are redundant with Specifications)
- if (cleaned.includes('<h3>Product Information</h3>') || cleaned.includes('<h4>Product Information</h4>')) {
- console.log(` Found "Product Information" section - removing`);
- cleaned = cleaned.replace(/<h[34]>Product\s+Information<\/h[34]>\s*<ul>[\s\S]*?<\/ul>/gi, '');
- removed = true;
- }
-
- // Pattern 3: Remove standalone spec lines like "Brand: X", "Collection: Y"
- const redundantPatterns = [
- /<p>\s*<strong>\s*Brand:\s*<\/strong>[^<]*<\/p>/gi,
- /<p>\s*<strong>\s*Collection:\s*<\/strong>[^<]*<\/p>/gi,
- /<p>\s*<strong>\s*Designer:\s*<\/strong>[^<]*<\/p>/gi,
- /<p>\s*<strong>\s*Type:\s*<\/strong>[^<]*<\/p>/gi,
- /<p>\s*<strong>\s*Product\s+Type:\s*<\/strong>[^<]*<\/p>/gi,
- ];
-
- for (const pattern of redundantPatterns) {
- if (pattern.test(cleaned)) {
- cleaned = cleaned.replace(pattern, '');
- removed = true;
- }
- }
-
- // Clean up extra whitespace
- cleaned = cleaned.replace(/\n\s*\n\s*\n/g, '\n\n').trim();
-
- return { cleaned, removed };
-}
-
-async function updateProductDescription(productId: string, newBodyHtml: string): Promise<void> {
- const url = `https://${SHOPIFY_STORE}/admin/api/${SHOPIFY_API_VERSION}/products/${productId}.json`;
-
- const response = await fetch(url, {
- method: 'PUT',
- headers: {
- 'X-Shopify-Access-Token': SHOPIFY_TOKEN,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- product: {
- id: productId,
- body_html: newBodyHtml,
- },
- }),
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to update product ${productId}: ${response.statusText} - ${errorText}`);
- }
-}
-
-async function main() {
- console.log('🧹 DWWC Description Cleanup - Remove Duplicate Specifications\n');
- console.log('='.repeat(80) + '\n');
-
- const products = await getAllDWWCProducts();
-
- let processedCount = 0;
- let updatedCount = 0;
- let errorCount = 0;
-
- for (const product of products) {
- processedCount++;
-
- console.log(`[${processedCount}/${products.length}] ${product.title}`);
-
- if (!product.body_html) {
- console.log(' ⏭️ Skipping - no description\n');
- continue;
- }
-
- const { cleaned, removed } = cleanDuplicateSpecs(product.body_html);
-
- if (!removed) {
- console.log(' ✓ No duplicates found\n');
- continue;
- }
-
- try {
- await updateProductDescription(product.id, cleaned);
- updatedCount++;
- console.log(' ✅ Updated successfully\n');
- } catch (error) {
- errorCount++;
- console.error(` ❌ Error: ${error}\n`);
- }
-
- // Rate limiting: 2 requests per second
- await new Promise(resolve => setTimeout(resolve, 500));
- }
-
- console.log('='.repeat(80));
- console.log('\n📊 FINAL SUMMARY:');
- console.log(` Total products: ${products.length}`);
- console.log(` Updated: ${updatedCount}`);
- console.log(` Errors: ${errorCount}`);
- console.log(` No changes needed: ${products.length - updatedCount - errorCount}`);
- console.log('\n✅ Cleanup complete!');
-}
-
-main().catch(console.error);
diff --git a/DW-Programming/ImportNewSkufromURL/update-all-schumacher-products.ts.bak b/DW-Programming/ImportNewSkufromURL/update-all-schumacher-products.ts.bak
deleted file mode 100644
index 0969743f..00000000
--- a/DW-Programming/ImportNewSkufromURL/update-all-schumacher-products.ts.bak
+++ /dev/null
@@ -1,514 +0,0 @@
-/**
- * Update ALL Schumacher products with data and images from official Schumacher website
- */
-
-import * as dotenv from 'dotenv';
-import * as path from 'path';
-
-// Load .env.local file
-dotenv.config({ path: path.join(__dirname, '.env.local') });
-
-interface SchumacherProduct {
- id: string;
- title: string;
- sku: string;
-}
-
-interface SchumacherProductData {
- sku: string;
- name: string;
- color: string;
- description: string;
- collection: string;
- width: string;
- horizontalRepeat: string;
- matchType: string;
- coverage: string;
- pretrim: string;
- railroaded: string;
- motif: string;
- scale: string;
- colors: string;
- careInstructions: string;
- flameTest: string;
- countryOfOrigin: string;
- imageUrls: string[];
-}
-
-async function getAllSchumacherProducts(): Promise<SchumacherProduct[]> {
- console.log('📦 Fetching all Schumacher products from Shopify...\n');
-
- const SHOPIFY_STORE = process.env.SHOPIFY_STORE_DOMAIN;
- const SHOPIFY_API_VERSION = process.env.SHOPIFY_ADMIN_API_VERSION || '2024-07';
- const SHOPIFY_ACCESS_TOKEN = process.env.SHOPIFY_ADMIN_ACCESS_TOKEN;
-
- if (!SHOPIFY_STORE || !SHOPIFY_ACCESS_TOKEN) {
- throw new Error('Missing required environment variables');
- }
-
- const GRAPHQL_ENDPOINT = `https://${SHOPIFY_STORE}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`;
-
- const allProducts: SchumacherProduct[] = [];
- let hasNextPage = true;
- let cursor: string | null = null;
-
- while (hasNextPage) {
- const query = `
- query GetSchumacherProducts($cursor: String) {
- products(first: 50, after: $cursor, query: "vendor:'Schumacher Wallpaper' AND status:active") {
- edges {
- node {
- id
- title
- variants(first: 1) {
- edges {
- node {
- sku
- }
- }
- }
- }
- cursor
- }
- pageInfo {
- hasNextPage
- }
- }
- }
- `;
-
- const response = await fetch(GRAPHQL_ENDPOINT, {
- method: 'POST',
- headers: {
- 'X-Shopify-Access-Token': SHOPIFY_ACCESS_TOKEN,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ query, variables: { cursor } }),
- });
-
- const result: any = await response.json();
-
- if (result.errors) {
- throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
- }
-
- const edges = result.data.products.edges;
- for (const edge of edges) {
- const sku = edge.node.variants.edges[0]?.node.sku || '';
- // Extract Schumacher SKU (5-7 digits, remove prefixes/suffixes)
- const schumacherSku = sku.match(/\d{5,7}/)?.[0] || sku;
-
- allProducts.push({
- id: edge.node.id,
- title: edge.node.title,
- sku: schumacherSku,
- });
- }
-
- hasNextPage = result.data.products.pageInfo.hasNextPage;
- cursor = edges.length > 0 ? edges[edges.length - 1].cursor : null;
-
- console.log(` Fetched ${allProducts.length} products so far...`);
- }
-
- console.log(`\n✅ Total Schumacher products: ${allProducts.length}\n`);
- return allProducts;
-}
-
-async function scrapeSchumacherProduct(sku: string, retryCount = 0): Promise<SchumacherProductData | null> {
- console.log(` 🔍 Scraping Schumacher data for ${sku}...`);
-
- try {
- // Try to fetch the product page to verify it exists
- const testUrl = `https://schumacher.com/catalog/products/${sku}?source=DISCOVER`;
- const testResponse = await fetch(testUrl);
-
- // Handle 429 with exponential backoff
- if (testResponse.status === 429 && retryCount < 3) {
- const waitTime = Math.pow(2, retryCount) * 10000; // 10s, 20s, 40s
- console.log(` ⏳ Rate limited, waiting ${waitTime / 1000}s before retry ${retryCount + 1}/3...`);
- await new Promise(resolve => setTimeout(resolve, waitTime));
- return scrapeSchumacherProduct(sku, retryCount + 1);
- }
-
- if (!testResponse.ok) {
- console.log(` ⚠️ Product ${sku} not found on Schumacher.com (${testResponse.status})`);
- return null;
- }
-
- const html = await testResponse.text();
-
- // Parse key data from HTML
- const titleMatch = html.match(/<title>([^<]+)<\/title>/);
- const title = titleMatch ? titleMatch[1].replace(' | Schumacher', '').trim() : '';
-
- // Split title into name and color (usually "Pattern Name - Color")
- const [name, color] = title.split(' - ').map(s => s.trim());
-
- // Extract description
- const descMatch = html.match(/"description":"([^"]+)"/);
- const description = descMatch ? descMatch[1] : '';
-
- // Extract collection
- const collectionMatch = html.match(/"collection":\{"name":"([^"]+)"/);
- const collection = collectionMatch ? collectionMatch[1] : '';
-
- // Extract ALL images from the HTML (find all srcset and src with the SKU)
- const imageUrls: string[] = [];
- const imageMatches = html.matchAll(/https:\/\/cdn-webassets\.fschumacher\.com\/catalog\/[^"']+\.(?:webp|jpg)/g);
-
- for (const match of imageMatches) {
- const url = match[0];
- // Prefer larger sizes: hd-webp > md-webp > sm-webp > xs-webp (hd is highest quality)
- if (url.includes('/hd-webp/') || url.includes('/md-webp/') || url.includes('/sm-webp/') || url.includes('/xs-webp/')) {
- // Replace with hd-webp for highest quality
- const highResUrl = url.replace(/(xs|sm|md)-webp/, 'hd-webp');
- if (!imageUrls.includes(highResUrl)) {
- imageUrls.push(highResUrl);
- }
- }
- }
-
- // If no images found, try standard pattern with hd-webp
- if (imageUrls.length === 0) {
- imageUrls.push(
- `https://cdn-webassets.fschumacher.com/catalog/hd-webp/${sku}.webp`,
- `https://cdn-webassets.fschumacher.com/catalog/hd-webp/${sku}-1.webp`,
- `https://cdn-webassets.fschumacher.com/catalog/hd-webp/${sku}-2.webp`,
- `https://cdn-webassets.fschumacher.com/catalog/hd-webp/${sku}-3.webp`,
- );
- }
-
- // Extract specs (simplified - just get what we can from HTML)
- const widthMatch = html.match(/"width":\{"value":"([^"]+)"/);
- const width = widthMatch ? widthMatch[1] : '';
-
- const repeatMatch = html.match(/"horizontal_repeat":\{"value":"([^"]+)"/);
- const horizontalRepeat = repeatMatch ? repeatMatch[1] : '';
-
- // Extract match type
- const matchTypeMatch = html.match(/"match_type":\{"value":"([^"]+)"/);
- const matchType = matchTypeMatch ? matchTypeMatch[1] : '';
-
- // Extract coverage
- const coverageMatch = html.match(/"coverage":\{"value":"([^"]+)"/);
- const coverage = coverageMatch ? coverageMatch[1] : '';
-
- // Extract motif
- const motifMatch = html.match(/"motif":\{"value":"([^"]+)"/);
- const motif = motifMatch ? motifMatch[1] : '';
-
- // Extract scale
- const scaleMatch = html.match(/"scale":\{"value":"([^"]+)"/);
- const scale = scaleMatch ? scaleMatch[1] : '';
-
- // Extract care instructions
- const careMatch = html.match(/"care":\{"value":"([^"]+)"/);
- const careInstructions = careMatch ? careMatch[1] : '';
-
- // Extract country of origin
- const countryMatch = html.match(/"country_of_origin":\{"value":"([^"]+)"/);
- const countryOfOrigin = countryMatch ? countryMatch[1] : '';
-
- // Extract pretrim
- const pretrimMatch = html.match(/"pretrimmed":\{"value":"([^"]+)"/);
- const pretrim = pretrimMatch ? pretrimMatch[1] : '';
-
- // Extract railroaded
- const railroadedMatch = html.match(/"railroaded":\{"value":"([^"]+)"/);
- const railroaded = railroadedMatch ? railroadedMatch[1] : '';
-
- // Extract colors from JSON data (e.g., "color":[{"value":"Black"},...])
- const colorMatches = html.matchAll(/"color":\[([^\]]+)\]/g);
- const extractedColors: string[] = [];
- for (const match of colorMatches) {
- const colorJson = match[1];
- // Extract all "value" fields from the color array
- const valueMatches = colorJson.matchAll(/"value":"([^"]+)"/g);
- for (const valueMatch of valueMatches) {
- const colorValue = valueMatch[1];
- if (colorValue && !extractedColors.includes(colorValue)) {
- extractedColors.push(colorValue);
- }
- }
- }
- const colors = extractedColors.join(', ');
-
- console.log(` ✅ Found: ${name} - ${color} (${imageUrls.length} images, colors: ${colors || 'none'})`);
-
- return {
- sku,
- name: name || title,
- color: color || '',
- description: description || `Premium ${title} wallcovering from Schumacher`,
- collection: collection || '',
- width: width || '',
- horizontalRepeat: horizontalRepeat || '',
- matchType: matchType || '',
- coverage: coverage || '',
- pretrim: pretrim || '',
- railroaded: railroaded || '',
- motif: motif || '',
- scale: scale || '',
- colors: colors,
- careInstructions: careInstructions || '',
- flameTest: 'ASTM E84 Class A',
- countryOfOrigin: countryOfOrigin || '',
- imageUrls,
- };
- } catch (error) {
- console.log(` ❌ Error scraping ${sku}: ${error}`);
- return null;
- }
-}
-
-async function updateProductWithSchumacherData(
- productId: string,
- productTitle: string,
- schumacherData: SchumacherProductData
-): Promise<boolean> {
- console.log(` 📝 Updating product description...`);
-
- const SHOPIFY_STORE = process.env.SHOPIFY_STORE_DOMAIN!;
- const SHOPIFY_API_VERSION = process.env.SHOPIFY_ADMIN_API_VERSION || '2024-07';
- const SHOPIFY_ACCESS_TOKEN = process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!;
- const GRAPHQL_ENDPOINT = `https://${SHOPIFY_STORE}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`;
-
- // Build description
- const specs = [];
- if (schumacherData.name) specs.push(`<li><strong>Pattern:</strong> ${schumacherData.name}</li>`);
- if (schumacherData.color) specs.push(`<li><strong>Color:</strong> ${schumacherData.color}</li>`);
- if (schumacherData.collection) specs.push(`<li><strong>Collection:</strong> ${schumacherData.collection}</li>`);
- specs.push(`<li><strong>SKU:</strong> ${schumacherData.sku}</li>`);
- if (schumacherData.width) specs.push(`<li><strong>Width:</strong> ${schumacherData.width}</li>`);
- if (schumacherData.horizontalRepeat) specs.push(`<li><strong>Horizontal Repeat:</strong> ${schumacherData.horizontalRepeat}</li>`);
- if (schumacherData.matchType) specs.push(`<li><strong>Match:</strong> ${schumacherData.matchType}</li>`);
- if (schumacherData.coverage) specs.push(`<li><strong>Coverage:</strong> ${schumacherData.coverage}</li>`);
- if (schumacherData.motif) specs.push(`<li><strong>Motif:</strong> ${schumacherData.motif}</li>`);
- if (schumacherData.scale) specs.push(`<li><strong>Scale:</strong> ${schumacherData.scale}</li>`);
- if (schumacherData.pretrim) specs.push(`<li><strong>Pretrimmed:</strong> ${schumacherData.pretrim}</li>`);
- if (schumacherData.railroaded) specs.push(`<li><strong>Railroaded:</strong> ${schumacherData.railroaded}</li>`);
- if (schumacherData.careInstructions) specs.push(`<li><strong>Care Instructions:</strong> ${schumacherData.careInstructions}</li>`);
- // ALWAYS include flame test - extremely important
- specs.push(`<li><strong>Flame Test:</strong> ${schumacherData.flameTest}</li>`);
- if (schumacherData.countryOfOrigin) specs.push(`<li><strong>Country of Origin:</strong> ${schumacherData.countryOfOrigin}</li>`);
-
- const updatedDescription = `
-<h3>Description</h3>
-<p>${schumacherData.description}</p>
-
-<h3>Specifications</h3>
-<ul>
-${specs.join('\n')}
-</ul>
-`.trim();
-
- // Build new title with actual Schumacher name and color
- const newTitle = schumacherData.color
- ? `${schumacherData.name} - ${schumacherData.color} | Schumacher`
- : `${schumacherData.name} | Schumacher`;
-
- // Build tags array
- const tags: string[] = ['Schumacher', 'Wallpaper', 'ASTM E84 Class A'];
- if (sku) tags.push(sku); // Add SKU to tags
- if (schumacherData.collection) tags.push(schumacherData.collection);
- if (schumacherData.name) tags.push(schumacherData.name);
- if (schumacherData.color) tags.push(schumacherData.color);
- if (schumacherData.motif) tags.push(schumacherData.motif);
- // Add individual colors from the colors field
- if (schumacherData.colors) {
- const individualColors = schumacherData.colors.split(', ').map(c => c.trim());
- for (const colorTag of individualColors) {
- if (colorTag && !tags.includes(colorTag)) {
- tags.push(colorTag);
- }
- }
- }
-
- // Update description, title, AND tags
- const updateMutation = `
- mutation UpdateProduct($input: ProductInput!) {
- productUpdate(input: $input) {
- product {
- id
- title
- tags
- }
- userErrors {
- field
- message
- }
- }
- }
- `;
-
- try {
- const updateResponse = await fetch(GRAPHQL_ENDPOINT, {
- method: 'POST',
- headers: {
- 'X-Shopify-Access-Token': SHOPIFY_ACCESS_TOKEN,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- query: updateMutation,
- variables: {
- input: {
- id: productId,
- title: newTitle,
- descriptionHtml: updatedDescription,
- tags: tags,
- },
- },
- }),
- });
-
- const updateResult: any = await updateResponse.json();
-
- if (updateResult.errors || updateResult.data?.productUpdate?.userErrors?.length > 0) {
- console.log(` ⚠️ Update error: ${JSON.stringify(updateResult.errors || updateResult.data.productUpdate.userErrors)}`);
- return false;
- }
-
- console.log(` ✅ Title updated: ${newTitle}`);
- console.log(` ✅ Description updated`);
- console.log(` ✅ Tags updated: ${tags.join(', ')}`);
-
- // Upload images
- console.log(` 📸 Uploading images...`);
- let uploadedCount = 0;
-
- for (const imageUrl of schumacherData.imageUrls) {
- // Check if image exists first
- const imageCheck = await fetch(imageUrl, { method: 'HEAD' });
- if (!imageCheck.ok) {
- console.log(` ⏭️ Skipping ${imageUrl.split('/').pop()} (404)`);
- continue;
- }
- console.log(` ⬇️ ${imageUrl.split('/').pop()}`);
-
- const imageMutation = `
- mutation productCreateMedia($media: [CreateMediaInput!]!, $productId: ID!) {
- productCreateMedia(media: $media, productId: $productId) {
- media {
- alt
- status
- }
- mediaUserErrors {
- field
- message
- }
- }
- }
- `;
-
- const imageResponse = await fetch(GRAPHQL_ENDPOINT, {
- method: 'POST',
- headers: {
- 'X-Shopify-Access-Token': SHOPIFY_ACCESS_TOKEN,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- query: imageMutation,
- variables: {
- productId: productId,
- media: [
- {
- alt: `${schumacherData.name} - ${schumacherData.color}`,
- mediaContentType: 'IMAGE',
- originalSource: imageUrl,
- },
- ],
- },
- }),
- });
-
- const imageResult: any = await imageResponse.json();
-
- if (!imageResult.errors && imageResult.data?.productCreateMedia?.mediaUserErrors?.length === 0) {
- uploadedCount++;
- }
-
- // Rate limit
- await new Promise(resolve => setTimeout(resolve, 500));
- }
-
- console.log(` ✅ Uploaded ${uploadedCount} images`);
- return true;
- } catch (error) {
- console.log(` ❌ Error: ${error}`);
- return false;
- }
-}
-
-async function main() {
- console.log('🎨 Schumacher Bulk Product Updater\n');
- console.log('='.repeat(80) + '\n');
-
- try {
- // Step 1: Get all Schumacher products
- const products = await getAllSchumacherProducts();
-
- if (products.length === 0) {
- console.log('No Schumacher products found.');
- return;
- }
-
- let processedCount = 0;
- let updatedCount = 0;
- let skippedCount = 0;
- let errorCount = 0;
-
- // Step 2: Process each product
- for (const product of products) {
- processedCount++;
- console.log(`\n[${ processedCount}/${products.length}] ${product.title}`);
- console.log(` SKU: ${product.sku}`);
-
- // Skip if SKU doesn't look like a Schumacher SKU (5-7 digits)
- if (!/^\d{5,7}$/.test(product.sku)) {
- console.log(` ⏭️ Skipping - invalid Schumacher SKU format (need 5-7 digits, got: ${product.sku})`);
- skippedCount++;
- continue;
- }
-
- // Step 3: Scrape Schumacher data
- const schumacherData = await scrapeSchumacherProduct(product.sku);
-
- if (!schumacherData) {
- console.log(` ⏭️ Skipping - no data from Schumacher`);
- skippedCount++;
- continue;
- }
-
- // Step 4: Update product
- const success = await updateProductWithSchumacherData(
- product.id,
- product.title,
- schumacherData
- );
-
- if (success) {
- updatedCount++;
- } else {
- errorCount++;
- }
-
- // Rate limiting between products (10 seconds to avoid 429 errors)
- await new Promise(resolve => setTimeout(resolve, 10000));
- }
-
- console.log('\n' + '='.repeat(80));
- console.log('\n📊 FINAL SUMMARY:');
- console.log(` Total products: ${products.length}`);
- console.log(` Updated: ${updatedCount}`);
- console.log(` Skipped: ${skippedCount}`);
- console.log(` Errors: ${errorCount}`);
- console.log('\n✅ Bulk update complete!');
- } catch (error) {
- console.error('\n' + '='.repeat(80));
- console.error('❌ Fatal error:', error);
- process.exit(1);
- }
-}
-
-main();
diff --git a/DW-Programming/room-setting-app/app/api/auth/login-password/route.ts b/DW-Programming/room-setting-app/app/api/auth/login-password/route.ts
index 8163bfc2..a6486029 100644
--- a/DW-Programming/room-setting-app/app/api/auth/login-password/route.ts
+++ b/DW-Programming/room-setting-app/app/api/auth/login-password/route.ts
@@ -4,9 +4,27 @@ import bcrypt from 'bcryptjs';
import * as fs from 'fs';
import * as path from 'path';
-const AUTH_USERNAME = process.env.AUTH_USERNAME || 'admin';
-// Hardcoded hash for DW2025Secure! (bcrypt hash with special chars that env vars can't handle)
-const AUTH_PASSWORD_HASH = process.env.AUTH_PASSWORD_HASH || '$2b$10$GXL6hj.1OAdCJ7tDSPYIPuhJxq/MgIYynNyAFdqfrwG9T4yImb3H.';
+// Credentials must be supplied via DW_BASIC_AUTH ("user:password") OR an explicit
+// AUTH_PASSWORD_HASH (+ optional AUTH_USERNAME). Fail-closed at module load if
+// neither is set — never default to a hardcoded literal/hash.
+const DW_BASIC_AUTH = process.env.DW_BASIC_AUTH;
+const AUTH_PASSWORD_HASH = process.env.AUTH_PASSWORD_HASH;
+
+let AUTH_USERNAME: string;
+let AUTH_PASSWORD_PLAINTEXT: string | null = null;
+
+if (DW_BASIC_AUTH) {
+ const colonIdx = DW_BASIC_AUTH.indexOf(':');
+ if (colonIdx <= 0 || colonIdx === DW_BASIC_AUTH.length - 1) {
+ throw new Error('DW_BASIC_AUTH must be in "user:password" form');
+ }
+ AUTH_USERNAME = DW_BASIC_AUTH.slice(0, colonIdx);
+ AUTH_PASSWORD_PLAINTEXT = DW_BASIC_AUTH.slice(colonIdx + 1);
+} else if (AUTH_PASSWORD_HASH) {
+ AUTH_USERNAME = process.env.AUTH_USERNAME || 'admin';
+} else {
+ throw new Error('DW_BASIC_AUTH env var required for auth — set in .env before boot');
+}
// Login tracking log file
const LOGIN_LOG_FILE = '/root/Projects/Designer-Wallcoverings/DW-Programming/room-setting-app/data/login-log.json';
@@ -72,19 +90,27 @@ export async function POST(request: NextRequest) {
);
}
- // Verify password hash
- if (!AUTH_PASSWORD_HASH) {
- console.error('AUTH_PASSWORD_HASH not configured');
+ // Verify password — DW_BASIC_AUTH plaintext takes priority, AUTH_PASSWORD_HASH is fallback.
+ let isValid = false;
+ if (AUTH_PASSWORD_PLAINTEXT !== null) {
+ console.log('Attempting login (DW_BASIC_AUTH plaintext path):', { username });
+ // Constant-time compare to avoid timing side-channel on the plaintext path.
+ const supplied = Buffer.from(password);
+ const expected = Buffer.from(AUTH_PASSWORD_PLAINTEXT);
+ isValid = supplied.length === expected.length &&
+ require('crypto').timingSafeEqual(supplied, expected);
+ } else if (AUTH_PASSWORD_HASH) {
+ console.log('Attempting login (AUTH_PASSWORD_HASH path):', { username, hashLength: AUTH_PASSWORD_HASH.length });
+ isValid = await bcrypt.compare(password, AUTH_PASSWORD_HASH);
+ } else {
+ // Should be unreachable — module-load guard ensures one of the two is set.
+ console.error('No auth credential source configured');
return NextResponse.json(
{ success: false, error: 'Authentication not configured' },
{ status: 500 }
);
}
- // Compare password with hash
- console.log('Attempting login:', { username, hashLength: AUTH_PASSWORD_HASH?.length });
- const isValid = await bcrypt.compare(password, AUTH_PASSWORD_HASH);
-
if (!isValid) {
console.log('Password comparison failed');
logLogin({ timestamp: new Date().toISOString(), username, ip, userAgent, success: false });
diff --git a/DW-Websites/.gitignore b/DW-Websites/.gitignore
index 7e6a9c3e..2c0d4829 100644
--- a/DW-Websites/.gitignore
+++ b/DW-Websites/.gitignore
@@ -10,3 +10,44 @@ dist/
build/
.next/
*.bak
+*.bak-*
+
+# DTD verdict 2026-05-29 — sweep of 519-file dirty state.
+# Each site below is an independent project (own .gitignore / .env /
+# package.json; LosAngelesFabrics has its own .git/). They should be
+# gitified per-project, NOT bundled into the DW-Websites parent monorepo.
+# The parent repo tracks only shared assets (_shared/, prod-mirrored
+# theme templates) + repo-level docs.
+Novasuede/
+GrassclothWallcoverings/
+GrassclothWallpaper.com/
+flockedwallpaper/
+flockedwallpaper-mobile/
+glassbeadedwallpaper/
+GoldLeafWallpaper/
+LosAngelesFabrics/
+Mobile/
+
+# Scratch / experimental theme template variants.
+DesignerWallcoverings-product-CLEAN.liquid
+product.liquid
+product-WORKING.liquid
+product-FIXED.liquid
+product-OPTIMIZED.liquid
+product-FINAL-CLEAN.liquid
+_prod-mirror-product-template.liquid
+
+# Local snapshots + reports (point-in-time, not source of truth).
+backups/
+STATUS_REPORT_*.md
+
+# Operational guardian scripts (per-host cron content, not portable code).
+*-guardian.sh
+dw-guardian-template.sh
+monitor-sites.sh
+site-watchdog.sh
+steve-hourly-report.sh
+novasuede-guardian.sh
+multi-method-test.sh
+quick-headless-test.js
+test-all-sites-headless.js
diff --git a/DW-Websites/GoldLeafWallpaper/index.html b/DW-Websites/GoldLeafWallpaper/index.html
index 65d6ca70..c3dd609b 100644
--- a/DW-Websites/GoldLeafWallpaper/index.html
+++ b/DW-Websites/GoldLeafWallpaper/index.html
@@ -70,8 +70,19 @@
.search input::placeholder{color:var(--muted)}
.search svg{width:16px;height:16px;stroke:var(--muted);fill:none;stroke-width:1.5}
+ /* Sort + density controls (hard-rule: every product grid) */
+ .controls{display:flex;gap:18px;align-items:center;margin-bottom:24px;flex-wrap:wrap}
+ .controls label,.controls .dens-lbl{font-size:10px;letter-spacing:0.22em;text-transform:uppercase;font-weight:700;color:var(--muted)}
+ .controls select{background:transparent;color:var(--paper);border:1px solid var(--line);font-family:inherit;font-size:11px;letter-spacing:0.10em;text-transform:uppercase;padding:6px 10px;margin-left:8px;cursor:pointer;outline:none}
+ .controls select option{background:var(--bg);color:var(--paper)}
+ .ctl-density{display:flex;align-items:center;gap:12px;margin-left:auto}
+ .ctl-density input[type=range]{-webkit-appearance:none;appearance:none;width:160px;height:1px;background:var(--line);outline:none}
+ .ctl-density input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:13px;height:13px;background:var(--gold);cursor:pointer;border-radius:50%}
+ .ctl-density input[type=range]::-moz-range-thumb{width:13px;height:13px;background:var(--gold);cursor:pointer;border-radius:50%;border:0}
+ .dens-ct{font-size:11px;color:var(--muted);letter-spacing:0.18em;text-transform:uppercase;font-weight:600;min-width:56px}
+
/* Pattern grid */
- .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0}
+ .grid{display:grid;grid-template-columns:repeat(var(--cols,5),1fr);gap:0}
.item{position:relative;aspect-ratio:1/1.15;cursor:pointer;overflow:hidden;border:1px solid rgba(255,255,255,0.06);transition:transform 0.4s cubic-bezier(0.2,0.8,0.2,1)}
.item:hover{transform:scale(1.04);z-index:5}
/* Crop the "PHILLIPE ROMANO / WALLCOVER · FABRIC · LOS ANGELES" watermark band
@@ -129,6 +140,8 @@
.footer-grid{grid-template-columns:1fr;gap:32px}
.grid{grid-template-columns:repeat(2,1fr)}
.search{margin-left:0;flex:1 1 100%}
+ .ctl-density{margin-left:0}
+ .ctl-density input[type=range]{width:120px}
}
</style>
</head>
@@ -173,6 +186,22 @@
</div>
</div>
+ <div class="controls">
+ <label>Sort
+ <select id="sortSelect" aria-label="Sort patterns">
+ <option value="newest">Newest</option>
+ <option value="color">Color</option>
+ <option value="sku">SKU A–Z</option>
+ <option value="title">Title A–Z</option>
+ </select>
+ </label>
+ <div class="ctl-density">
+ <span class="dens-lbl">Density</span>
+ <input type="range" id="densitySlider" min="3" max="8" step="1" value="5" aria-label="Grid columns">
+ <span class="dens-ct" id="densityLabel">5 cols</span>
+ </div>
+ </div>
+
<div class="grid" id="grid"></div>
</section>
@@ -252,6 +281,7 @@
let products = [];
let currentFilter = 'all';
let currentSearch = '';
+ let currentSort = 'newest';
function deriveDisplay(p) {
// Pull color tags (Gold, Silver, Black, etc.) for display
@@ -300,7 +330,25 @@
}
return true;
});
- render(filtered);
+ render(sortProducts(filtered));
+ }
+
+ function sortProducts(arr){
+ const a = arr.slice();
+ switch(currentSort){
+ case 'color':
+ return a.sort((x,y)=>{
+ const cx=(deriveDisplay(x).colorTags[0]||'zzz').toLowerCase();
+ const cy=(deriveDisplay(y).colorTags[0]||'zzz').toLowerCase();
+ return cx.localeCompare(cy) || String(x.code).localeCompare(String(y.code), undefined, {numeric:true});
+ });
+ case 'sku':
+ return a.sort((x,y)=>String(x.code).localeCompare(String(y.code), undefined, {numeric:true}));
+ case 'title':
+ return a.sort((x,y)=>String(x.title||'').localeCompare(String(y.title||'')));
+ default: // newest — server's natural order
+ return a;
+ }
}
document.querySelectorAll('.chip').forEach(chip => {
@@ -316,6 +364,35 @@
applyFilter();
});
+ // Sort control (localStorage-persisted)
+ const sortSel = document.getElementById('sortSelect');
+ if(sortSel){
+ try { currentSort = localStorage.getItem('gl_sort') || 'newest'; } catch(e){}
+ sortSel.value = currentSort;
+ sortSel.addEventListener('change', () => {
+ currentSort = sortSel.value;
+ try { localStorage.setItem('gl_sort', currentSort); } catch(e){}
+ applyFilter();
+ });
+ }
+
+ // Density slider — drives --cols on the grid (localStorage-persisted)
+ const densSlider = document.getElementById('densitySlider');
+ const densLabel = document.getElementById('densityLabel');
+ function setDensity(n){
+ const g = document.getElementById('grid');
+ if(g) g.style.setProperty('--cols', n);
+ if(densLabel) densLabel.textContent = n + ' cols';
+ try { localStorage.setItem('gl_density', n); } catch(e){}
+ }
+ if(densSlider){
+ let savedD = 5;
+ try { savedD = parseInt(localStorage.getItem('gl_density') || '5', 10) || 5; } catch(e){}
+ densSlider.value = savedD;
+ setDensity(savedD);
+ densSlider.addEventListener('input', () => setDensity(parseInt(densSlider.value, 10)));
+ }
+
// Modal
function openSampleModal(name, code, img) {
const modal = document.getElementById('sampleModal');
diff --git a/DW-Websites/GrassclothWallcoverings/.gitignore b/DW-Websites/GrassclothWallcoverings/.gitignore
index c1b218b8..6f524b54 100644
--- a/DW-Websites/GrassclothWallcoverings/.gitignore
+++ b/DW-Websites/GrassclothWallcoverings/.gitignore
@@ -1,8 +1,16 @@
+# --- canonical .gitignore (Steve standing rule, gitify sweep 2026-05-30) ---
node_modules/
.env
+.env*
+.env.local
+dist/
+build/
+.next/
*.log
.DS_Store
-server.log
-keep-alive.log
+tmp/
+*.bak
+*.bak-*
.claude/
.vercel
+.vercel/
diff --git a/DW-Websites/GrassclothWallcoverings/index.html b/DW-Websites/GrassclothWallcoverings/index.html
index 181de8b1..51a9387e 100644
--- a/DW-Websites/GrassclothWallcoverings/index.html
+++ b/DW-Websites/GrassclothWallcoverings/index.html
@@ -98,6 +98,7 @@
</script>
<link rel="stylesheet" href="styles.css">
+ <script src="/corner-nav.js" defer></script>
</head>
<body>
<!-- Header -->
diff --git a/DW-Websites/GrassclothWallcoverings/products-shopify-v4.js b/DW-Websites/GrassclothWallcoverings/products-shopify-v4.js
index 25e36230..a12d7cd7 100644
--- a/DW-Websites/GrassclothWallcoverings/products-shopify-v4.js
+++ b/DW-Websites/GrassclothWallcoverings/products-shopify-v4.js
@@ -136,7 +136,33 @@ function createNewModal() {
// Global products array for sorting
let allProducts = [];
-let currentSortOrder = 'name-asc';
+let currentSortOrder = localStorage.getItem('gcw_sort') || 'newest';
+
+// --- Sort helpers (HARD RULE: every product grid needs sort + density) ---
+// Color buckets: group similar hues by keyword in the resolved color name.
+const COLOR_BUCKETS = [
+ { name: 'White / Cream', rx: /(white|ivory|cream|linen|pearl|oyster|parchment|champagne|opal|cloud|dove|fog|mist|platinum|silver|blush|peach)/i },
+ { name: 'Neutral / Tan', rx: /(natural|tan|sand|wheat|beige|taupe|khaki|stone|camel|honey|butterscotch|driftwood|oyster|smoke|ash|graphite)/i },
+ { name: 'Brown', rx: /(brown|chocolate|espresso|mocha|cocoa|walnut|chestnut|mahogany|hickory|cedar|tobacco|cinnamon|sienna|auburn|ginger|rust|copper|bronze|amber|terracotta|coral|caramel)/i },
+ { name: 'Gray / Black', rx: /(gray|grey|charcoal|pewter|slate|carbon|ebony|onyx|shadow|graphite|smoke|carbon)/i },
+ { name: 'Green', rx: /(green|sage|olive|moss|jade|mint|seafoam|forest|teal)/i },
+ { name: 'Blue', rx: /(blue|navy|sky|midnight)/i },
+ { name: 'Purple / Pink', rx: /(plum|mauve|burgundy|blush|coral|peach)/i },
+ { name: 'Gold / Yellow', rx: /(gold|amber|honey|butterscotch)/i }
+];
+function colorBucket(name) {
+ for (let i = 0; i < COLOR_BUCKETS.length; i++) {
+ if (COLOR_BUCKETS[i].rx.test(name)) return i;
+ }
+ return COLOR_BUCKETS.length; // unmatched goes last
+}
+// Style buckets: coarse texture/style grouping for grasscloth color names.
+function styleBucket(name) {
+ if (/(white|ivory|cream|linen|pearl|natural|tan|sand|wheat|beige|champagne|oyster|parchment)/i.test(name)) return 0; // Light / Neutral
+ if (/(gray|grey|charcoal|pewter|slate|silver|platinum|graphite|smoke|ash|fog|mist|dove|cloud|shadow|carbon|ebony|onyx)/i.test(name)) return 1; // Cool / Modern
+ if (/(brown|chocolate|espresso|mocha|walnut|chestnut|mahogany|hickory|cedar|tobacco|cinnamon|sienna|auburn|rust|copper|bronze|amber|terracotta)/i.test(name)) return 2; // Warm / Earthy
+ return 3; // Accent / Bold
+}
// Function to render products
function renderProducts(products, colorMap) {
@@ -191,67 +217,94 @@ function renderProducts(products, colorMap) {
console.log(`Displayed ${products.length} Hollywood Abaca products`);
}
-// Function to sort products
+// Function to sort products. "Newest" = server's natural array order.
function sortProducts(sortOrder, colorMap) {
const sortedProducts = [...allProducts];
+ const nameOf = (p) => colorMap[p.sku] || 'Natural';
+ const skuOf = (p) => p.sku || p.handle || '';
- switch(sortOrder) {
- case 'name-asc':
+ switch (sortOrder) {
+ case 'color':
sortedProducts.sort((a, b) => {
- const nameA = colorMap[a.sku] || 'Natural';
- const nameB = colorMap[b.sku] || 'Natural';
- return nameA.localeCompare(nameB);
+ const ba = colorBucket(nameOf(a)), bb = colorBucket(nameOf(b));
+ return ba - bb || nameOf(a).localeCompare(nameOf(b));
});
break;
- case 'price-low':
+ case 'style':
sortedProducts.sort((a, b) => {
- return parseFloat(a.price) - parseFloat(b.price);
+ const sa = styleBucket(nameOf(a)), sb = styleBucket(nameOf(b));
+ return sa - sb || nameOf(a).localeCompare(nameOf(b));
});
break;
- case 'price-high':
- sortedProducts.sort((a, b) => {
- return parseFloat(b.price) - parseFloat(a.price);
- });
+ case 'sku-asc':
+ sortedProducts.sort((a, b) => skuOf(a).localeCompare(skuOf(b), undefined, { numeric: true }));
+ break;
+ case 'title-asc':
+ sortedProducts.sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
+ break;
+ case 'newest':
+ default:
+ // leave in natural (server) order
break;
}
renderProducts(sortedProducts, colorMap);
}
-// Function to create sort dropdown
-function createSortDropdown(colorMap) {
- const productsSection = document.querySelector('#products .container');
- if (!productsSection) return;
-
- const sortContainer = document.createElement('div');
- sortContainer.style.cssText = 'display: flex; justify-content: flex-end; margin-bottom: 20px; gap: 10px; align-items: center;';
-
- const sortLabel = document.createElement('label');
- sortLabel.textContent = 'Sort by:';
- sortLabel.style.cssText = 'font-weight: 500; color: #333;';
- sortLabel.setAttribute('for', 'sortDropdown');
-
- const sortSelect = document.createElement('select');
- sortSelect.id = 'sortDropdown';
- sortSelect.style.cssText = 'padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; background: white; cursor: pointer; font-size: 14px;';
-
- sortSelect.innerHTML = `
- <option value="name-asc">Name A-Z</option>
- <option value="price-low">Price Low-High</option>
- <option value="price-high">Price High-Low</option>
+// Function to create the sort + density controls bar above the grid.
+// HARD RULE: every DW product grid needs a sort <select> + density slider,
+// both persisted to localStorage.
+function createGridControls(colorMap) {
+ const grid = document.getElementById('productsGrid');
+ if (!grid || !grid.parentNode) return;
+ if (document.getElementById('gridControls')) return; // idempotent
+
+ const bar = document.createElement('div');
+ bar.id = 'gridControls';
+ bar.className = 'grid-controls';
+ bar.innerHTML = `
+ <div class="ctrl">
+ <label for="sortSelect">Sort</label>
+ <select id="sortSelect">
+ <option value="newest">Newest</option>
+ <option value="color">Color</option>
+ <option value="style">Style</option>
+ <option value="sku-asc">SKU A→Z</option>
+ <option value="title-asc">Title A→Z</option>
+ </select>
+ </div>
+ <div class="ctrl">
+ <label for="densitySlider">Grid</label>
+ <input type="range" id="densitySlider" min="4" max="10" step="1" value="4">
+ <span class="density-value" id="densityLabel">4 cols</span>
+ </div>
`;
+ grid.parentNode.insertBefore(bar, grid);
+ // --- Sort wire-up ---
+ const sortSelect = document.getElementById('sortSelect');
+ sortSelect.value = currentSortOrder;
sortSelect.addEventListener('change', (e) => {
currentSortOrder = e.target.value;
+ try { localStorage.setItem('gcw_sort', currentSortOrder); } catch (err) {}
sortProducts(currentSortOrder, colorMap);
});
- sortContainer.appendChild(sortLabel);
- sortContainer.appendChild(sortSelect);
-
- const grid = document.getElementById('productsGrid');
- if (grid && grid.parentNode) {
- grid.parentNode.insertBefore(sortContainer, grid);
+ // --- Density wire-up ---
+ const slider = document.getElementById('densitySlider');
+ const dlabel = document.getElementById('densityLabel');
+ function setDensity(n) {
+ document.documentElement.style.setProperty('--cols', n);
+ dlabel.textContent = n + ' cols';
+ try { localStorage.setItem('gcw_density', n); } catch (err) {}
+ }
+ slider.addEventListener('input', (e) => setDensity(parseInt(e.target.value, 10)));
+ const savedDensity = parseInt(localStorage.getItem('gcw_density') || '4', 10);
+ if (savedDensity >= 4 && savedDensity <= 10) {
+ slider.value = savedDensity;
+ setDensity(savedDensity);
+ } else {
+ setDensity(4);
}
}
@@ -358,10 +411,10 @@ window.addEventListener('load', async function() {
// Store products globally for sorting
allProducts = data.products;
- // Create sort dropdown
- createSortDropdown(colorMap);
+ // Create sort + density controls (HARD RULE)
+ createGridControls(colorMap);
- // Display all products with default sorting (Name A-Z)
+ // Display all products using the persisted/default sort
sortProducts(currentSortOrder, colorMap);
} catch (err) {
diff --git a/DW-Websites/GrassclothWallcoverings/styles.css b/DW-Websites/GrassclothWallcoverings/styles.css
index 96e40225..0ed2dd1c 100644
--- a/DW-Websites/GrassclothWallcoverings/styles.css
+++ b/DW-Websites/GrassclothWallcoverings/styles.css
@@ -14,6 +14,7 @@
--border-color: #d1d5db;
--corporate-blue: #003d5b;
--hospitality-gold: #d4af37;
+ --cols: 4;
}
body {
@@ -162,11 +163,58 @@ header {
.products-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ grid-template-columns: repeat(var(--cols), 1fr);
gap: 2rem;
margin-top: 3rem;
}
+/* Tablet: clamp the slider so cards never get too small */
+@media (min-width: 769px) and (max-width: 1024px) {
+ .products-grid {
+ grid-template-columns: repeat(min(var(--cols), 4), 1fr);
+ }
+}
+
+/* Sort + density controls above the grid (HARD RULE) */
+.grid-controls {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 1.25rem;
+ margin: 2rem 0 0;
+}
+.grid-controls .ctrl {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.grid-controls label {
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: var(--primary-color);
+ letter-spacing: 0.02em;
+}
+.grid-controls select {
+ padding: 8px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ background: #fff;
+ color: var(--text-color);
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+.grid-controls input[type="range"] {
+ accent-color: var(--secondary-color);
+ cursor: pointer;
+ width: 130px;
+}
+.grid-controls .density-value {
+ font-size: 0.8rem;
+ color: var(--text-color);
+ min-width: 3.5em;
+}
+
.product-card {
background: white;
border: 1px solid var(--border-color);
@@ -747,11 +795,15 @@ footer {
}
.products-grid {
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ grid-template-columns: repeat(min(var(--cols), 2), 1fr);
gap: 0.75rem;
padding: 0 0.5rem;
}
+ .grid-controls {
+ justify-content: center;
+ }
+
.product-card {
-webkit-tap-highlight-color: transparent;
}
diff --git a/DW-Websites/GrassclothWallpaper.com/.gitignore b/DW-Websites/GrassclothWallpaper.com/.gitignore
index 581e12c4..eb018aec 100644
--- a/DW-Websites/GrassclothWallpaper.com/.gitignore
+++ b/DW-Websites/GrassclothWallpaper.com/.gitignore
@@ -1,7 +1,18 @@
+# --- canonical .gitignore (Steve standing rule, gitify sweep 2026-05-30) ---
node_modules/
.env
+.env*
+.env.local
+dist/
+build/
+.next/
*.log
.DS_Store
-server.log
-keep-alive.log
-.claude/
\ No newline at end of file
+tmp/
+*.bak
+*.bak-*
+.claude/
+.vercel
+.vercel/
+# --- large-file excludes (>5MB, gitify sweep) ---
+grasscloth-collection-all.json
diff --git a/DW-Websites/GrassclothWallpaper.com/index.html b/DW-Websites/GrassclothWallpaper.com/index.html
index e2c17467..f9106079 100644
--- a/DW-Websites/GrassclothWallpaper.com/index.html
+++ b/DW-Websites/GrassclothWallpaper.com/index.html
@@ -663,6 +663,78 @@
<script src="/corner-nav.js" defer></script>
</head>
<body>
+<!-- Theme toggle (theme1 / theme2) — query-param-gated preview surface
+ (Steve 2026-05-29). Mirror of dw-domain-fleet shared/render.js productPage()
+ block (commit 34fbe0e). No admin auth → security-by-obscurity: ?theme=2
+ flips to theme2 and persists in localStorage 'wallco-page-theme'. Toggle UI
+ hidden until ?theme=N or localStorage opt-in. Shares the wallco-page-theme
+ localStorage key for cross-site continuity. -->
+<script>
+(function(){
+ var qs = new URLSearchParams(location.search);
+ var fromUrl = qs.get('theme');
+ var saved = null; try { saved = localStorage.getItem('wallco-page-theme'); } catch(e){}
+ var theme = null;
+ if (fromUrl === '2' || fromUrl === 'theme2') theme = 'theme2';
+ else if (fromUrl === '1' || fromUrl === 'theme1') theme = 'theme1';
+ else if (saved === 'theme2' || saved === 'theme1') theme = saved;
+ if (theme) {
+ document.documentElement.setAttribute('data-page-theme', theme);
+ if (fromUrl) { try { localStorage.setItem('wallco-page-theme', theme); } catch(e){} }
+ }
+ /* No opt-in → no attribute set, toggle stays display:none (gate works). */
+})();
+</script>
+<style id="page-theme-css-fleet">
+ [data-page-theme="theme2"] body{
+ --bg:#ffffff !important; --ink:#0a0a0a !important;
+ --muted:#5a5a5a !important; --line:#e5e5e5 !important;
+ --card-bg:#fafafa !important; --accent:#c14a2e !important;
+ background:#fff !important; color:#0a0a0a !important;
+ }
+ [data-page-theme="theme2"] h1,
+ [data-page-theme="theme2"] h2,
+ [data-page-theme="theme2"] h3{ letter-spacing:-0.01em; font-weight:500; }
+ [data-page-theme="theme2"] .card{
+ background:#fafafa !important; border:1px solid #e5e5e5 !important; box-shadow:none !important;
+ }
+ #page-theme-toggle{ display:none; }
+ [data-page-theme] #page-theme-toggle{ display:inline-flex; }
+ #page-theme-toggle{
+ position:fixed; top:96px; right:14px; z-index:90;
+ gap:0; align-items:center;
+ background:rgba(255,255,255,.92); border:1px solid #d8d2c5;
+ border-radius:999px; padding:3px; backdrop-filter:blur(6px);
+ box-shadow:0 2px 10px rgba(0,0,0,.08);
+ font:600 11px ui-sans-serif,system-ui; letter-spacing:.06em; text-transform:uppercase;
+ }
+ #page-theme-toggle button{
+ border:0; background:transparent; color:#5a5048; cursor:pointer;
+ padding:6px 14px; border-radius:999px; font:inherit; transition:all .15s;
+ }
+ #page-theme-toggle button[aria-pressed="true"]{ background:#1a1714; color:#faf7f2; }
+ #page-theme-toggle button:hover:not([aria-pressed="true"]){ color:#1a1714; }
+</style>
+<div id="page-theme-toggle" role="group" aria-label="Page theme">
+ <button type="button" data-theme="theme1" aria-pressed="false">Theme 1</button>
+ <button type="button" data-theme="theme2" aria-pressed="false">Theme 2</button>
+</div>
+<script>
+(function(){
+ var cur = document.documentElement.getAttribute('data-page-theme') || 'theme1';
+ var btns = document.querySelectorAll('#page-theme-toggle button');
+ function sync(t){ btns.forEach(function(b){ b.setAttribute('aria-pressed', b.dataset.theme === t ? 'true' : 'false'); }); }
+ sync(cur);
+ btns.forEach(function(b){
+ b.addEventListener('click', function(){
+ var t = b.dataset.theme;
+ document.documentElement.setAttribute('data-page-theme', t);
+ try { localStorage.setItem('wallco-page-theme', t); } catch(e){}
+ sync(t);
+ });
+ });
+})();
+</script>
<!-- Header -->
<header class="header">
<div class="header-content">
diff --git a/DW-Websites/GrassclothWallpaper.com/server.js b/DW-Websites/GrassclothWallpaper.com/server.js
index 5ac014f4..c78495d5 100644
--- a/DW-Websites/GrassclothWallpaper.com/server.js
+++ b/DW-Websites/GrassclothWallpaper.com/server.js
@@ -1,8 +1,34 @@
const http = require('http');
+const handleSubstituteRequest = require('./raw-substitute-handler');
const fs = require('fs');
const path = require('path');
+require('dotenv').config({ path: '/root/DW-Agents/claudette-agent/.env' });
+const { Pool } = require('pg');
+const GEORGE_PORT = 9850;
+const GEORGE_AUTH = process.env.GEORGE_AUTH ||
+ ('Basic ' + Buffer.from(process.env.GEORGE_BASIC_AUTH || 'admin:I3YusisdESUNdxtrmlb3QJeu9q8ODKJO').toString('base64'));
+
const PORT = process.env.PORT || 8000;
+const pool = new Pool({ connectionString: process.env.DB_URL || process.env.DATABASE_URL });
+
+let productsCache = null;
+let productsCacheAt = 0;
+async function getProducts() {
+ const now = Date.now();
+ if (productsCache && now - productsCacheAt < 60_000) return productsCache;
+ const { rows } = await pool.query(`
+ SELECT sku, name, description, image_url, hex_color, hex2_color,
+ lightness, hue, saturation, color_variation,
+ vendor, product_type, price, source_url, available_for_sale
+ FROM grasscloth_products
+ WHERE image_url IS NOT NULL
+ ORDER BY hue NULLS LAST, lightness NULLS LAST, sku
+ `);
+ productsCache = rows;
+ productsCacheAt = now;
+ return rows;
+}
const mimeTypes = {
'.html': 'text/html',
@@ -18,11 +44,30 @@ const mimeTypes = {
};
const server = http.createServer((req, res) => {
+ // Substitute request intake (drop-in handler)
+ if (req.method === "POST" && req.url === "/api/send-substitute-request") {
+ return handleSubstituteRequest(req, res);
+ }
+
// Add CORS headers for all requests
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+ if (req.url === '/api/products' || req.url.startsWith('/api/products?')) {
+ getProducts()
+ .then(rows => {
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60' });
+ res.end(JSON.stringify(rows));
+ })
+ .catch(err => {
+ console.error('/api/products error:', err.message);
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'db_error', message: err.message }));
+ });
+ return;
+ }
+
// Handle image requests from /images/ path
if (req.url.startsWith('/images/')) {
const imageName = req.url.replace('/images/', '');
@@ -133,7 +178,10 @@ const server = http.createServer((req, res) => {
}
}
- let filePath = '.' + req.url;
+ // Strip query string before filesystem lookup so `/?theme=2` resolves
+ // to `./` (then to ./index.html) rather than `./?theme=2` (404).
+ // Steve 2026-05-29 — required for the wallco-page-theme ?theme=N toggle.
+ let filePath = '.' + req.url.split('?')[0];
if (filePath === './') {
filePath = './index.html';
}
diff --git a/DW-Websites/LosAngelesFabrics b/DW-Websites/LosAngelesFabrics
index fecd589b..6119610b 160000
--- a/DW-Websites/LosAngelesFabrics
+++ b/DW-Websites/LosAngelesFabrics
@@ -1 +1 @@
-Subproject commit fecd589bcb9612d6d50b8b775aafd9d287be2d27
+Subproject commit 6119610ba6b615336f72f19670e8594f37f663c4
diff --git a/DW-Websites/Novasuede/.gitignore b/DW-Websites/Novasuede/.gitignore
index 4c49bd78..a73c839f 100644
--- a/DW-Websites/Novasuede/.gitignore
+++ b/DW-Websites/Novasuede/.gitignore
@@ -1 +1,19 @@
+# --- canonical .gitignore (Steve standing rule, gitify sweep 2026-05-30) ---
+node_modules/
.env
+.env*
+.env.local
+dist/
+build/
+.next/
+*.log
+.DS_Store
+tmp/
+*.bak
+*.bak-*
+.claude/
+.vercel
+.vercel/
+# --- large-file excludes (>5MB, gitify sweep) ---
+images/left-curtain-d1.png
+images/right-curtain-d1.png
diff --git a/DW-Websites/Novasuede/index.html b/DW-Websites/Novasuede/index.html
index b95fcb9d..8033e945 100644
--- a/DW-Websites/Novasuede/index.html
+++ b/DW-Websites/Novasuede/index.html
@@ -2734,5 +2734,6 @@
<!-- Central sample-request hub. Any element with data-dw-sample triggers the
hub modal; existing chip-level Sample buttons keep their inline modal. -->
<script src="https://auth.agentabrams.com/widget/sample.js" data-site="novasuede.com" defer></script>
+ <script src="/corner-nav.js" defer></script>
</body>
</html>
\ No newline at end of file
diff --git a/DW-Websites/_prod-mirror/product.liquid.bak-pre-design-coordinate b/DW-Websites/_prod-mirror/product.liquid.bak-pre-design-coordinate
deleted file mode 100644
index 0de7cbf3..00000000
--- a/DW-Websites/_prod-mirror/product.liquid.bak-pre-design-coordinate
+++ /dev/null
@@ -1,355 +0,0 @@
-{% render 'breadcrumbs' %}
-
-{% liquid
- assign enable_zoom = section.settings.enable_zoom
- assign enable_cart_redirection = section.settings.enable_cart_redirection
- assign images_layout = section.settings.images_layout
- assign enable_video_autoplay = section.settings.enable_video_autoplay
- assign enable_video_looping = section.settings.enable_video_looping
- assign enable_linked_options = true
- assign show_vendor = settings.show_vendor
- assign show_social_media_icons = section.settings.show_social_media_icons
- assign show_payment_button = section.settings.show_payment_button
-
- for block in section.blocks
- if block.type == 'complementary_products'
- assign product_recommendation_limit = block.settings.product_recommendation_limit
- break
- endif
- endfor
-%}
-
-{% if images_layout == 'masonry' %}
- {% comment %}Related products in masonry grid must be below product{% endcomment %}
- {% assign related_products_position_right = false %}
-{% endif %}
-
-<script
- type="application/json"
- data-section-type="product"
- data-section-id="{{ section.id }}"
- data-section-data
->
- {
- "product": {{ product | json }},
- "product_settings": {
- "addToCartText": {{ 'products.product.add_to_cart' | t | json }},
- "enableHistory": true,
- "processingText": {{ 'products.product.processing' | t | json }},
- "setQuantityText": {{ 'products.product.set_quantity' | t | json }},
- "soldOutText": {{ 'products.product.sold_out' | t | json }},
- "unavailableText": {{ 'products.product.unavailable' | t | json }}
- },
- "images_layout": {{ images_layout | json }},
- "enable_zoom": {{ enable_zoom | json }},
- "enable_video_autoplay": {{ enable_video_autoplay | json }},
- "enable_video_looping": {{ enable_video_looping | json }},
- "enable_cart_redirection": {{ enable_cart_redirection | json }},
- "enable_fixed_positioning": true,
- "product_recommendation_limit": {{ product_recommendation_limit | json }}
- }
-</script>
-
-{% render 'product-success-labels' %}
-<section class="product-container">
- {%
- render 'product',
- product: product,
- enable_zoom: enable_zoom,
- images_layout: images_layout,
- enable_linked_options: enable_linked_options,
- show_vendor: show_vendor,
- show_social_media_icons: show_social_media_icons,
- show_payment_button: show_payment_button,
- %}
-
- <div
- class="product-recommendations-wrapper--right"
- data-product-recommendations-right
- >
- </div>
-</section>
-
-{% schema %}
-{
- "name": "Product pages",
- "settings": [
- {
- "type": "checkbox",
- "id": "enable_cart_redirection",
- "label": "Enable cart redirection",
- "info": "Automatically sends users to the Cart page after adding a product.",
- "default": false
- },
- {
- "type": "checkbox",
- "id": "show_payment_button",
- "label": "Show dynamic checkout button",
- "info": "Each customer will see their preferred payment method from those available on your store, such as PayPal or Apple Pay. [Learn more](https://help.shopify.com/manual/using-themes/change-the-layout/dynamic-checkout)",
- "default": true
- },
- {
- "type": "header",
- "content": "Media"
- },
- {
- "type": "paragraph",
- "content": "Learn more about [media types](https://help.shopify.com/en/manual/products/product-media/product-media-types)"
- },
- {
- "type": "select",
- "id": "images_layout",
- "label": "Layout",
- "options": [
- {
- "label": "Slideshow",
- "value": "slideshow"
- },
- {
- "label": "List",
- "value": "list"
- },
- {
- "label": "Masonry",
- "value": "masonry"
- }
- ],
- "default": "slideshow"
- },
- {
- "type": "checkbox",
- "id": "enable_zoom",
- "label": "Enable image zoom",
- "info": "Zoom only works with the slideshow image layout"
- },
- {
- "type": "checkbox",
- "id": "enable_video_autoplay",
- "label": "Enable video autoplay",
- "default": true
- },
- {
- "type": "checkbox",
- "id": "enable_video_looping",
- "label": "Enable video looping"
- }
- ],
- "blocks": [
- {
- "type": "@app"
- },
- {
- "type": "collapsible-tab",
- "name": "Collapsible tab",
- "settings": [
- {
- "type": "text",
- "id": "collapsible_tab_heading",
- "label": "Heading",
- "default": "Collapsible tab"
- },
- {
- "type": "richtext",
- "id": "collapsible_tab_text",
- "label": "Text",
- "default": "<p>Use this text to share information about your product.</p>"
- }
- ]
- },
- {
- "type": "custom-liquid",
- "name": "Custom liquid",
- "settings": [
- {
- "type": "liquid",
- "id": "custom_liquid",
- "label": "Custom liquid",
- "info": "Add app snippets or other Liquid code to create advanced customizations."
- }
- ]
- },
- {
- "type": "tabs",
- "name": "Tabs",
- "limit": 1,
- "settings": [
- {
- "type": "checkbox",
- "id": "show_product_description",
- "label": "Show product description",
- "default": false
- },
- {
- "type": "header",
- "content": "Tab"
- },
- {
- "type": "text",
- "id": "tab_heading_1",
- "label": "Heading",
- "default": "Tab 1"
- },
- {
- "type": "richtext",
- "id": "tab_text_1",
- "label": "Text",
- "default": "<p>Tab 1 content goes here.</p>"
- },
- {
- "type": "header",
- "content": "Tab"
- },
- {
- "type": "text",
- "id": "tab_heading_2",
- "label": "Heading",
- "default": "Tab 2"
- },
- {
- "type": "richtext",
- "id": "tab_text_2",
- "label": "Text",
- "default": "<p>Tab 2 content goes here.</p>"
- },
- {
- "type": "header",
- "content": "Tab"
- },
- {
- "type": "text",
- "id": "tab_heading_3",
- "label": "Heading",
- "default": "Tab 3"
- },
- {
- "type": "richtext",
- "id": "tab_text_3",
- "label": "Text",
- "default": "<p>Tab 3 content goes here.</p>"
- }
- ]
- },
- {
- "type": "title",
- "name": "Title",
- "limit": 1
- },
- {
- "type": "vendor",
- "name": "Vendor",
- "limit": 1
- },
- {
- "type": "social",
- "name": "Social",
- "limit": 1
- },
- {
- "type": "description",
- "name": "Description",
- "limit": 1
- },
- {
- "type": "price",
- "name": "Price",
- "limit": 1
- },
- {
- "type": "form",
- "name": "Form",
- "limit": 1,
- "settings": [
- {
- "type": "paragraph",
- "content": "Customize form features for the product in the products portion of the theme settings."
- },
- {
- "type": "checkbox",
- "id": "show_gift_card_recipient_form",
- "label": "t:sections.product.blocks.form.show_gift_card_recipient_form.label",
- "info": "t:sections.product.blocks.form.show_gift_card_recipient_form.info",
- "default": false
- }
- ]
- },
- {
- "type": "rating",
- "name": "Product rating",
- "limit": 1,
- "settings": [
- {
- "type": "paragraph",
- "content": "To display a rating, add a product rating app. [Learn more](https://apps.shopify.com/product-reviews)"
- }
- ]
- },
- {
- "type": "complementary_products",
- "name": "Complementary products",
- "limit": 1,
- "settings": [
- {
- "type": "paragraph",
- "content": "To select complementary products, add the Search & Discovery app. [Learn more](https:\/\/shopify.dev\/themes\/product-merchandising\/recommendations)"
- },
- {
- "type": "text",
- "id": "heading",
- "label": "Heading",
- "default": "Pairs well with"
- },
- {
- "type": "range",
- "id": "product_recommendation_limit",
- "label": "Maximum products to show",
- "min": 1,
- "max": 10,
- "default": 5
- },
- {
- "type": "range",
- "id": "products_per_slide",
- "label": "Number of products per page",
- "min": 1,
- "max": 3,
- "default": 2
- }
- ]
- }
- ]
-}
-
-{% endschema %}
-
-<div class="keywords-desktop" style="padding-left: 0ch; max-width: 80ch; line-height: 1.6;">
- <h4 style="padding-left: 3ch; margin-bottom: 0;">KEYWORDS:</h4>
- <div style="padding-left: 3ch;">
- {% assign line = "" %}
- {% assign char_limit = 96 %}
- {% assign separator = ", " %}
- {% assign plain_line = "" %}
- {% for tag in product.tags %}
- {% unless tag == "display_variant" %}
- {% assign tag_link = '<a href="/search?q=' | append: tag | append: '">' | append: tag | append: '</a>' %}
- {% assign tag_text = tag | append: separator %}
- {% assign test_line = plain_line | append: tag_text %}
- {% assign test_size = test_line | size %}
- {% if test_size > char_limit %}
- {{ line }}<br>
- {% assign line = tag_link | append: separator %}
- {% assign plain_line = tag_text %}
- {% else %}
- {% assign line = line | append: tag_link | append: separator %}
- {% assign plain_line = test_line %}
- {% endif %}
- {% endunless %}
- {% endfor %}
- {{ line }}
- </div>
-</div>
-
-{% comment %} Recently Viewed Products — tracks current product + shows carousel {% endcomment %}
-{% if settings.dw_enable_recently_viewed %}
-
- {% render 'recently-viewed' %}
-{% endif %}
diff --git a/DW-Websites/flockedwallpaper/.gitignore b/DW-Websites/flockedwallpaper/.gitignore
index 4c49bd78..6f524b54 100644
--- a/DW-Websites/flockedwallpaper/.gitignore
+++ b/DW-Websites/flockedwallpaper/.gitignore
@@ -1 +1,16 @@
+# --- canonical .gitignore (Steve standing rule, gitify sweep 2026-05-30) ---
+node_modules/
.env
+.env*
+.env.local
+dist/
+build/
+.next/
+*.log
+.DS_Store
+tmp/
+*.bak
+*.bak-*
+.claude/
+.vercel
+.vercel/
diff --git a/DW-Websites/glassbeadedwallpaper/.gitignore b/DW-Websites/glassbeadedwallpaper/.gitignore
index 4c49bd78..6f524b54 100644
--- a/DW-Websites/glassbeadedwallpaper/.gitignore
+++ b/DW-Websites/glassbeadedwallpaper/.gitignore
@@ -1 +1,16 @@
+# --- canonical .gitignore (Steve standing rule, gitify sweep 2026-05-30) ---
+node_modules/
.env
+.env*
+.env.local
+dist/
+build/
+.next/
+*.log
+.DS_Store
+tmp/
+*.bak
+*.bak-*
+.claude/
+.vercel
+.vercel/
diff --git a/US-001-IMPLEMENTATION.md.pre-scrub-2026-05-07.bak b/US-001-IMPLEMENTATION.md.pre-scrub-2026-05-07.bak
deleted file mode 100644
index 749b276f..00000000
--- a/US-001-IMPLEMENTATION.md.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,194 +0,0 @@
-# US-001: Shopify API Integration Service - Implementation Summary
-
-## Status: ✅ COMPLETED
-
-## Story Details
-**Title:** Create Shopify API Integration Service
-**Description:** As a system, I want a service to connect to Shopify Admin API so that I can retrieve and update product information programmatically.
-
-## Acceptance Criteria - All Met ✅
-- ✅ Service connects to Shopify Admin API with proper authentication
-- ✅ Can fetch product lists by vendor with pagination
-- ✅ Can update individual product fields (title, description, tags, type, category)
-- ✅ Handles API rate limiting with appropriate delays
-- ✅ Typecheck passes
-
-## Implementation Details
-
-### Files Modified
-1. **lib/shopify-client.ts** - Enhanced with new update methods
- - Added `updateProductTitle(productId, title)` - Update product title
- - Added `updateProductDescription(productId, description)` - Update product description (body_html)
- - Added `updateProductType(productId, productType)` - Update product type
- - Added `updateProduct(productId, updates)` - Batch update multiple fields
- - Enhanced `ShopifyProduct` interface to include `body_html` field
- - Existing methods: `getProducts()`, `getAllProducts()`, `updateProductTags()`
-
-2. **scripts/test-us-001-shopify-service.ts** - Comprehensive test suite
- - Tests authentication and connection
- - Tests vendor filtering with pagination
- - Tests automatic pagination (getAllProducts)
- - Verifies rate limiting (2 calls/second max)
- - Documents all update methods
-
-### Key Features
-
-#### Authentication
-- Uses environment variables: `SHOPIFY_ADMIN_ACCESS_TOKEN`, `SHOPIFY_STORE_DOMAIN`, `SHOPIFY_ADMIN_API_VERSION`
-- Secure token-based authentication via X-Shopify-Access-Token header
-- Configuration in `.env` file
-
-#### Rate Limiting
-- Enforces 500ms minimum between requests (2 calls/second max)
-- Automatic delay insertion between consecutive API calls
-- Prevents API rate limit errors
-
-#### Pagination
-- Manual pagination via `getProducts({ vendor, limit, sinceId })`
-- Automatic pagination via `getAllProducts(vendor)` - retrieves all products
-- Uses `since_id` parameter for cursor-based pagination
-- Maximum 250 products per request (Shopify limit)
-
-#### Update Methods
-1. **Single Field Updates:**
- ```typescript
- await client.updateProductTitle(productId, 'New Title');
- await client.updateProductDescription(productId, '<p>New description</p>');
- await client.updateProductTags(productId, ['Tag1', 'Tag2']);
- await client.updateProductType(productId, 'Wallcovering');
- ```
-
-2. **Batch Updates:**
- ```typescript
- await client.updateProduct(productId, {
- title: 'New Title',
- description: '<p>New description</p>',
- tags: ['Tag1', 'Tag2'],
- product_type: 'Wallcovering'
- });
- ```
-
-#### Error Handling
-- Typed error responses via `ShopifyApiError` interface
-- Network error detection and reporting
-- HTTP status code checking
-- Detailed error messages
-
-## Test Results
-
-### Test Run Output (Jan 22, 2026)
-```
-=== US-001: Shopify API Integration Service Test ===
-
-Test 1: Connection and authentication
-✅ Successfully connected to Shopify API
- Retrieved 1 product(s)
-
-Test 2: Fetch products by vendor with pagination
-✅ Successfully fetched 10 products for vendor "Koroseal"
- First product: "() | (DWK-31482-Sample) | Architectural Wallcoverings" (ID: 7584976699443)
-
-Test 3: Automatic pagination (getAllProducts)
-✅ Successfully fetched all products with pagination
- Total products for "Phillipe Romano": 1527
-
-Test 4: Rate limiting verification
- Making 5 consecutive requests to verify rate limiting...
-✅ Rate limiting working correctly (2170ms elapsed, expected >=2000ms)
-
-Test 5: Update methods verification
- Available update methods:
- ✅ updateProductTitle(productId, title)
- ✅ updateProductDescription(productId, description)
- ✅ updateProductTags(productId, tags[])
- ✅ updateProductType(productId, productType)
- ✅ updateProduct(productId, { title, description, tags, product_type })
-```
-
-### TypeScript Compilation
-```bash
-$ npm run build
-> designer-wallcoverings@1.0.0 build
-> tsc --noEmit
-
-# No errors - typecheck passes ✅
-```
-
-## Usage Examples
-
-### Fetch Products by Vendor
-```typescript
-import { ShopifyClient } from './lib/shopify-client';
-
-const client = new ShopifyClient();
-
-// Get first 10 products from a vendor
-const products = await client.getProducts({
- vendor: 'Koroseal',
- limit: 10
-});
-
-// Get all products from a vendor (with automatic pagination)
-const allProducts = await client.getAllProducts('Phillipe Romano');
-console.log(`Total products: ${allProducts.length}`);
-```
-
-### Update Product Fields
-```typescript
-import { ShopifyClient } from './lib/shopify-client';
-
-const client = new ShopifyClient();
-const productId = 7584976699443;
-
-// Update single field
-await client.updateProductTitle(productId, 'New Product Title');
-
-// Update multiple fields at once
-await client.updateProduct(productId, {
- title: 'Updated Title',
- description: '<p>Updated description with HTML</p>',
- tags: ['Azure', 'Modern', 'Pattern'],
- product_type: 'Wallcovering'
-});
-```
-
-## API Configuration
-
-### Environment Variables (.env)
-```bash
-SHOPIFY_STORE_DOMAIN=designer-laboratory-sandbox.myshopify.com
-SHOPIFY_ADMIN_API_VERSION=2024-01
-SHOPIFY_ADMIN_ACCESS_TOKEN=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
-```
-
-### Shopify API Endpoints Used
-- `GET /admin/api/2024-01/products.json` - List products
-- `GET /admin/api/2024-01/products/{id}.json` - Get single product
-- `PUT /admin/api/2024-01/products/{id}.json` - Update product
-
-## Git Commit
-```
-commit 0c06e65fe7611bd09009af9ea14043b7c7d79a27
-Author: Ralph <ralph@designer-wallcoverings.com>
-Date: Thu Jan 22 15:27:05 2026 +0000
-
- feat(US-001): Create Shopify API Integration Service
-
- - Enhanced ShopifyClient with comprehensive update methods
- - Added updateProductTitle() for title updates
- - Added updateProductDescription() for description updates
- - Added updateProductType() for product type updates
- - Added updateProduct() for batch field updates
- - Existing functionality: vendor filtering, pagination, rate limiting
- - All acceptance criteria met and verified
- - Typecheck passes
-
- Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
-
- lib/shopify-client.ts | 103 +++++++++++++++++++++++++++
- scripts/test-us-001-shopify-service.ts | 99 ++++++++++++++++++++++++++
- 2 files changed, 202 insertions(+)
-```
-
-## Next Steps
-US-001 is complete and ready for US-002.
diff --git a/ai-enrich-arte-fix.js.pre-scrub-2026-05-07.bak b/ai-enrich-arte-fix.js.pre-scrub-2026-05-07.bak
deleted file mode 100644
index c8b391e9..00000000
--- a/ai-enrich-arte-fix.js.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,116 +0,0 @@
-const https = require('https');
-const { Client } = require('pg');
-
-const GEMINI_KEY = 'REDACTED_GEMINI_KEY';
-const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`;
-
-const PRODUCTS = [
- {id:123,sku:'19433',name:'Horus Desert Sun',img:'https://edge.arte-international.com/media/4960d9f1-bc0a-4bcb-9a82-c09d9e5e7d98/conversions/EssentialsLuxor_Horus_19436_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:16,sku:'23401',name:'Amarna Bister',img:'https://edge.arte-international.com/media/cc56d30c-5e1c-4b22-878e-b437681e6f1b/conversions/Memphis_Amarna_23400_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:15,sku:'23412',name:'Sakkara Antique Gold',img:'https://edge.arte-international.com/media/a580214f-25ff-472e-b88c-6a2bd8dfab64/conversions/Memphis_Sakkara_23410_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:172,sku:'23413',name:'Sakkara Off-white',img:'https://edge.arte-international.com/media/a580214f-25ff-472e-b88c-6a2bd8dfab64/conversions/Memphis_Sakkara_23410_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:72,sku:'23421',name:'Ibis Biscuit',img:'https://edge.arte-international.com/media/2183e2bc-0620-43c4-81ae-6364abed3937/conversions/Memphis_Ibis_23420_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:75,sku:'23422',name:'Ibis Chestnut',img:'https://edge.arte-international.com/media/2183e2bc-0620-43c4-81ae-6364abed3937/conversions/Memphis_Ibis_23420_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:78,sku:'23431',name:'Siwa Bone',img:'https://edge.arte-international.com/media/428898de-d288-40f3-b463-1714c59de939/conversions/Memphis_Siwa_23432_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:83,sku:'23433',name:'Siwa Biscuit',img:'https://edge.arte-international.com/media/428898de-d288-40f3-b463-1714c59de939/conversions/Memphis_Siwa_23432_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:183,sku:'29221',name:'Galon Grey Linen',img:'https://edge.arte-international.com/media/8571da7a-7bf9-4abc-adf0-76863cee5413/conversions/Allures_Galon9224_1_Roomshot_Web_LR-medium-two-thirds.jpg'},
-];
-
-const PROMPT = `You are an interior design wallcovering product analyst. Analyze this wallcovering image and return ONLY valid JSON (no markdown, no backticks):
-{
- "colors": ["<list 3-6 actual colors you see, e.g. Warm Beige, Soft Gold>"],
- "backgroundColor": "<the single dominant background color>",
- "styles": ["<1-3 design styles, e.g. Contemporary, Art Deco, Minimalist>"],
- "patterns": ["<1-2 pattern types, e.g. Geometric, Textured, Striped>"],
- "tags": ["<5-8 interior design tags, e.g. luxury, neutral, organic>"],
- "description": "<2 sentences describing this wallcovering for a commercial interior designer>"
-}`;
-
-function fetchImage(url) {
- return new Promise((resolve, reject) => {
- https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- return fetchImage(res.headers.location).then(resolve).catch(reject);
- }
- if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
- const chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => resolve(Buffer.concat(chunks)));
- res.on('error', reject);
- }).on('error', reject);
- });
-}
-
-function callGemini(b64, name) {
- const body = JSON.stringify({
- contents: [{ parts: [
- { text: `Product: ${name}\n\n${PROMPT}` },
- { inlineData: { mimeType: 'image/jpeg', data: b64 } }
- ]}],
- generationConfig: { temperature: 0.2, maxOutputTokens: 1024 }
- });
- return new Promise((resolve, reject) => {
- const url = new URL(GEMINI_URL);
- const req = https.request({ hostname: url.hostname, path: url.pathname + url.search, method: 'POST', headers: { 'Content-Type': 'application/json' } }, (res) => {
- let data = '';
- res.on('data', c => data += c);
- res.on('end', () => {
- try {
- const json = JSON.parse(data);
- if (json.error) return reject(new Error(json.error.message));
- const text = json.candidates?.[0]?.content?.parts?.[0]?.text || '';
- resolve(JSON.parse(text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()));
- } catch (e) { reject(new Error(`Parse: ${e.message}`)); }
- });
- });
- req.on('error', reject);
- req.write(body);
- req.end();
- });
-}
-
-async function main() {
- const db = new Client({ connectionString: 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified' });
- await db.connect();
- let ok = 0, fail = 0;
-
- for (let i = 0; i < PRODUCTS.length; i++) {
- const p = PRODUCTS[i];
- console.log(`\n[${i+1}/9] ${p.sku} - ${p.name}`);
- try {
- const imgBuf = await fetchImage(p.img);
- console.log(` Image: ${(imgBuf.length/1024).toFixed(0)}KB`);
- const ai = await callGemini(imgBuf.toString('base64'), p.name);
- console.log(` Colors: ${ai.colors.join(', ')} | BG: ${ai.backgroundColor}`);
-
- // vendor_catalog: text columns - stringify arrays as JSON strings
- await db.query(`
- UPDATE vendor_catalog SET
- ai_colors = $1, ai_background_color = $2, ai_styles = $3,
- ai_patterns = $4, ai_tags = $5, ai_description = $6
- WHERE id = $7
- `, [JSON.stringify(ai.colors), ai.backgroundColor, JSON.stringify(ai.styles),
- JSON.stringify(ai.patterns), JSON.stringify(ai.tags), ai.description, p.id]);
-
- // arte_catalog: jsonb columns - also stringify for jsonb
- const arteRes = await db.query(`
- UPDATE arte_catalog SET
- ai_colors = $1::jsonb, ai_background_color = $2, ai_styles = $3::jsonb,
- ai_patterns = $4::jsonb, ai_tags = $5::jsonb, ai_description = $6
- WHERE arte_sku = $7
- `, [JSON.stringify(ai.colors), ai.backgroundColor, JSON.stringify(ai.styles),
- JSON.stringify(ai.patterns), JSON.stringify(ai.tags), ai.description, p.sku]);
-
- console.log(` Saved vendor_catalog + arte_catalog (${arteRes.rowCount} arte row)`);
- ok++;
- } catch (err) {
- console.log(` FAIL: ${err.message}`);
- fail++;
- }
- if (i < PRODUCTS.length - 1) await new Promise(r => setTimeout(r, 1200));
- }
-
- await db.end();
- console.log(`\nDONE: ${ok}/9 success, ${fail} failed`);
-}
-main().catch(console.error);
diff --git a/ai-enrich-gaps2.js.pre-scrub-2026-05-07.bak b/ai-enrich-gaps2.js.pre-scrub-2026-05-07.bak
deleted file mode 100644
index 1302da2f..00000000
--- a/ai-enrich-gaps2.js.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,133 +0,0 @@
-const https = require('https');
-const http = require('http');
-const { Client } = require('pg');
-
-const GEMINI_KEY = 'REDACTED_GEMINI_KEY';
-const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`;
-
-const PRODUCTS = [
- {id:123,vendor:'arte',sku:'19433',name:'Horus Desert Sun',img:'https://edge.arte-international.com/media/4960d9f1-bc0a-4bcb-9a82-c09d9e5e7d98/conversions/EssentialsLuxor_Horus_19436_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:16,vendor:'arte',sku:'23401',name:'Amarna Bister',img:'https://edge.arte-international.com/media/cc56d30c-5e1c-4b22-878e-b437681e6f1b/conversions/Memphis_Amarna_23400_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:15,vendor:'arte',sku:'23412',name:'Sakkara Antique Gold',img:'https://edge.arte-international.com/media/a580214f-25ff-472e-b88c-6a2bd8dfab64/conversions/Memphis_Sakkara_23410_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:172,vendor:'arte',sku:'23413',name:'Sakkara Off-white',img:'https://edge.arte-international.com/media/a580214f-25ff-472e-b88c-6a2bd8dfab64/conversions/Memphis_Sakkara_23410_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:72,vendor:'arte',sku:'23421',name:'Ibis Biscuit',img:'https://edge.arte-international.com/media/2183e2bc-0620-43c4-81ae-6364abed3937/conversions/Memphis_Ibis_23420_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:75,vendor:'arte',sku:'23422',name:'Ibis Chestnut',img:'https://edge.arte-international.com/media/2183e2bc-0620-43c4-81ae-6364abed3937/conversions/Memphis_Ibis_23420_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:78,vendor:'arte',sku:'23431',name:'Siwa Bone',img:'https://edge.arte-international.com/media/428898de-d288-40f3-b463-1714c59de939/conversions/Memphis_Siwa_23432_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:83,vendor:'arte',sku:'23433',name:'Siwa Biscuit',img:'https://edge.arte-international.com/media/428898de-d288-40f3-b463-1714c59de939/conversions/Memphis_Siwa_23432_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:183,vendor:'arte',sku:'29221',name:'Galon Grey Linen',img:'https://edge.arte-international.com/media/8571da7a-7bf9-4abc-adf0-76863cee5413/conversions/Allures_Galon9224_1_Roomshot_Web_LR-medium-two-thirds.jpg'},
- {id:1751,vendor:'black_edition',sku:'W924/01',name:'Mizumi Panel Carbon',img:'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-01FP-mizumi-panel-charcoal_00.jpg'},
- {id:1752,vendor:'black_edition',sku:'W924/02',name:'Mizumi Panel Midnight',img:'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-02FP-mizumi-panel-midnight_00.jpg'},
- {id:1753,vendor:'black_edition',sku:'W924/03',name:'Mizumi Panel Basalt',img:'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-03FP-mizumi-panel-stone_00.jpg'},
- {id:1754,vendor:'black_edition',sku:'W924/04',name:'Mizumi Panel Viridian',img:'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-04FP-mizumi-panel-teal_02.jpg'},
-];
-
-const PROMPT = `You are an interior design wallcovering product analyst. Analyze this wallcovering image and return ONLY valid JSON (no markdown, no backticks):
-{
- "colors": ["<list 3-6 actual colors you see, e.g. Warm Beige, Soft Gold>"],
- "backgroundColor": "<the single dominant background color>",
- "styles": ["<1-3 design styles, e.g. Contemporary, Art Deco, Minimalist>"],
- "patterns": ["<1-2 pattern types, e.g. Geometric, Textured, Striped>"],
- "tags": ["<5-8 interior design tags, e.g. luxury, neutral, organic>"],
- "description": "<2 sentences describing this wallcovering for a commercial interior designer>"
-}`;
-
-function fetchImage(url) {
- const mod = url.startsWith('https') ? https : http;
- return new Promise((resolve, reject) => {
- mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- return fetchImage(res.headers.location).then(resolve).catch(reject);
- }
- if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
- const chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => resolve(Buffer.concat(chunks)));
- res.on('error', reject);
- }).on('error', reject);
- });
-}
-
-function callGemini(b64, productName) {
- const body = JSON.stringify({
- contents: [{ parts: [
- { text: `Product: ${productName}\n\n${PROMPT}` },
- { inlineData: { mimeType: 'image/jpeg', data: b64 } }
- ]}],
- generationConfig: { temperature: 0.2, maxOutputTokens: 1024 }
- });
-
- return new Promise((resolve, reject) => {
- const url = new URL(GEMINI_URL);
- const req = https.request({
- hostname: url.hostname, path: url.pathname + url.search,
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- }, (res) => {
- let data = '';
- res.on('data', c => data += c);
- res.on('end', () => {
- try {
- const json = JSON.parse(data);
- if (json.error) return reject(new Error(json.error.message));
- const text = json.candidates?.[0]?.content?.parts?.[0]?.text || '';
- const clean = text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
- resolve(JSON.parse(clean));
- } catch (e) { reject(new Error(`Parse: ${e.message}`)); }
- });
- });
- req.on('error', reject);
- req.write(body);
- req.end();
- });
-}
-
-async function main() {
- const db = new Client({ connectionString: 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified' });
- await db.connect();
-
- let success = 0, fail = 0;
-
- for (let i = 0; i < PRODUCTS.length; i++) {
- const p = PRODUCTS[i];
- console.log(`\n[${i+1}/13] ${p.vendor} ${p.sku} - ${p.name}`);
-
- try {
- const imgBuf = await fetchImage(p.img);
- console.log(` Image: ${(imgBuf.length/1024).toFixed(0)}KB`);
- const b64 = imgBuf.toString('base64');
-
- const ai = await callGemini(b64, p.name);
- console.log(` Colors: ${(ai.colors||[]).join(', ')}`);
- console.log(` BG: ${ai.backgroundColor} | Styles: ${(ai.styles||[]).join(', ')}`);
-
- const res = await db.query(`
- UPDATE vendor_catalog SET
- ai_colors = $1, ai_background_color = $2, ai_styles = $3,
- ai_patterns = $4, ai_tags = $5, ai_description = $6
- WHERE id = $7
- `, [ai.colors, ai.backgroundColor, ai.styles, ai.patterns, ai.tags, ai.description, p.id]);
-
- if (p.vendor === 'arte') {
- await db.query(`
- UPDATE arte_catalog SET
- ai_colors = $1, ai_background_color = $2, ai_styles = $3,
- ai_patterns = $4, ai_tags = $5, ai_description = $6
- WHERE arte_sku = $7
- `, [ai.colors, ai.backgroundColor, ai.styles, ai.patterns, ai.tags, ai.description, p.sku]);
- }
-
- console.log(` Saved (${res.rowCount} row)`);
- success++;
- } catch (err) {
- console.log(` FAIL: ${err.message}`);
- fail++;
- }
-
- if (i < PRODUCTS.length - 1) await new Promise(r => setTimeout(r, 1200));
- }
-
- await db.end();
- console.log(`\nDONE: ${success} success, ${fail} failed out of 13`);
-}
-
-main().catch(console.error);
diff --git a/arte-fill-tags.js.pre-scrub-2026-05-07.bak b/arte-fill-tags.js.pre-scrub-2026-05-07.bak
deleted file mode 100644
index 978ced98..00000000
--- a/arte-fill-tags.js.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,123 +0,0 @@
-const https = require('https');
-const http = require('http');
-const { Client } = require('pg');
-
-const GEMINI_KEY = 'REDACTED_GEMINI_KEY';
-const GEMINI_MODEL = 'gemini-2.0-flash';
-const DB = 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified';
-
-async function geminiAnalyze(imageUrl, patternName) {
- const prompt = `You are an interior design wallcovering tagger. Analyze this wallcovering image.
-
-Product: ${patternName}
-
-Return ONLY a JSON object with:
-{
- "tags": ["tag1", "tag2", ...] // 5-10 interior design tags (e.g., "Botanical", "Luxury", "Traditional", "Textured", "Neutral Palette", "Organic", "Nature-Inspired")
-}
-
-Tags should describe: style, mood, pattern type, texture, suitable rooms, design era. Be specific to wallcoverings/interior design.`;
-
- const body = JSON.stringify({
- contents: [{
- parts: [
- { text: prompt },
- { inline_data: { mime_type: 'image/jpeg', data: await fetchImageBase64(imageUrl) } }
- ]
- }],
- generationConfig: { temperature: 0.2, maxOutputTokens: 300 }
- });
-
- return new Promise((resolve, reject) => {
- const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`;
- const parsed = new URL(url);
- const req = https.request({
- hostname: parsed.hostname,
- path: parsed.pathname + parsed.search,
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
- }, (res) => {
- let data = '';
- res.on('data', c => data += c);
- res.on('end', () => {
- try {
- const json = JSON.parse(data);
- const text = json.candidates?.[0]?.content?.parts?.[0]?.text || '';
- const cleaned = text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
- resolve(JSON.parse(cleaned));
- } catch(e) { reject(new Error(`Parse error: ${e.message} raw: ${data.substring(0,200)}`)); }
- });
- });
- req.on('error', reject);
- req.write(body);
- req.end();
- });
-}
-
-function fetchImageBase64(imageUrl) {
- return new Promise((resolve, reject) => {
- const mod = imageUrl.startsWith('https') ? https : http;
- mod.get(imageUrl, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- return fetchImageBase64(res.headers.location).then(resolve).catch(reject);
- }
- const chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => resolve(Buffer.concat(chunks).toString('base64')));
- }).on('error', reject);
- });
-}
-
-function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
-
-async function main() {
- const db = new Client(DB);
- await db.connect();
-
- const { rows } = await db.query(`
- SELECT id, mfr_sku, pattern_name, image_url
- FROM vendor_catalog
- WHERE vendor_code = 'arte'
- AND (ai_tags IS NULL OR ai_tags = '[]'::jsonb)
- AND image_url IS NOT NULL AND image_url != ''
- ORDER BY mfr_sku
- `);
-
- console.log(`Processing ${rows.length} Arte products for ai_tags...`);
- let success = 0, fail = 0;
-
- for (const row of rows) {
- try {
- console.log(` [${success+fail+1}/${rows.length}] ${row.mfr_sku} ${row.pattern_name}...`);
- const result = await geminiAnalyze(row.image_url, row.pattern_name);
-
- if (result.tags && Array.isArray(result.tags) && result.tags.length > 0) {
- await db.query(
- `UPDATE vendor_catalog SET ai_tags = $1 WHERE id = $2`,
- [JSON.stringify(result.tags), row.id]
- );
- console.log(` ✓ ${result.tags.length} tags: ${result.tags.join(', ')}`);
- success++;
- } else {
- console.log(` ✗ No tags returned`);
- fail++;
- }
- await sleep(1200); // rate limit
- } catch(e) {
- console.log(` ✗ Error: ${e.message}`);
- fail++;
- }
- }
-
- // Handle 67403 (no image) - mark for review
- await db.query(`
- UPDATE vendor_catalog
- SET ai_tags = '["Needs-Image-Review"]'::jsonb
- WHERE vendor_code = 'arte' AND mfr_sku = '67403' AND ai_colors IS NULL
- `);
-
- console.log(`\nDone! Success: ${success}, Failed: ${fail}, Skipped (no image): 1`);
- await db.end();
-}
-
-main().catch(e => { console.error(e); process.exit(1); });
diff --git a/enrich-arte-fix.js.pre-scrub-2026-05-07.bak b/enrich-arte-fix.js.pre-scrub-2026-05-07.bak
deleted file mode 100644
index 5b3c484d..00000000
--- a/enrich-arte-fix.js.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,139 +0,0 @@
-const https = require('https');
-const { Client } = require('pg');
-
-const GEMINI_KEY = 'REDACTED_GEMINI_KEY';
-const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`;
-const DB_URL = 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified';
-
-const PRODUCTS = [
- { id: 123, sku: '19433', pattern: 'Horus', color: 'Desert Sun', img: 'https://edge.arte-international.com/media/4960d9f1-bc0a-4bcb-9a82-c09d9e5e7d98/conversions/EssentialsLuxor_Horus_19436_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 16, sku: '23401', pattern: 'Amarna', color: 'Bister', img: 'https://edge.arte-international.com/media/cc56d30c-5e1c-4b22-878e-b437681e6f1b/conversions/Memphis_Amarna_23400_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 15, sku: '23412', pattern: 'Sakkara', color: 'Antique Gold', img: 'https://edge.arte-international.com/media/a580214f-25ff-472e-b88c-6a2bd8dfab64/conversions/Memphis_Sakkara_23410_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 172, sku: '23413', pattern: 'Sakkara', color: 'Off-white', img: 'https://edge.arte-international.com/media/a580214f-25ff-472e-b88c-6a2bd8dfab64/conversions/Memphis_Sakkara_23410_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 72, sku: '23421', pattern: 'Ibis', color: 'Biscuit', img: 'https://edge.arte-international.com/media/2183e2bc-0620-43c4-81ae-6364abed3937/conversions/Memphis_Ibis_23420_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 75, sku: '23422', pattern: 'Ibis', color: 'Chestnut', img: 'https://edge.arte-international.com/media/2183e2bc-0620-43c4-81ae-6364abed3937/conversions/Memphis_Ibis_23420_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 78, sku: '23431', pattern: 'Siwa', color: 'Bone', img: 'https://edge.arte-international.com/media/428898de-d288-40f3-b463-1714c59de939/conversions/Memphis_Siwa_23432_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 83, sku: '23433', pattern: 'Siwa', color: 'Biscuit', img: 'https://edge.arte-international.com/media/428898de-d288-40f3-b463-1714c59de939/conversions/Memphis_Siwa_23432_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 183, sku: '29221', pattern: 'Galon', color: 'Grey Linen', img: 'https://edge.arte-international.com/media/8571da7a-7bf9-4abc-adf0-76863cee5413/conversions/Allures_Galon9224_1_Roomshot_Web_LR-medium-two-thirds.jpg' },
-];
-
-function downloadImage(url) {
- return new Promise((resolve, reject) => {
- https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- return downloadImage(res.headers.location).then(resolve).catch(reject);
- }
- const chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => resolve(Buffer.concat(chunks)));
- res.on('error', reject);
- }).on('error', reject);
- });
-}
-
-function callGemini(b64, mime, prompt) {
- return new Promise((resolve, reject) => {
- const body = JSON.stringify({
- contents: [{ parts: [
- { text: prompt },
- { inline_data: { mime_type: mime, data: b64 } }
- ]}]
- });
- const url = new URL(GEMINI_URL);
- const req = https.request({
- hostname: url.hostname,
- path: url.pathname + url.search,
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
- timeout: 30000
- }, (res) => {
- const chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => {
- try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
- catch(e) { reject(e); }
- });
- });
- req.on('error', reject);
- req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
- req.write(body);
- req.end();
- });
-}
-
-function parseGeminiJson(text) {
- let clean = text.replace(/^```(?:json)?\s*/m, '').replace(/\s*```\s*$/m, '').trim();
- return JSON.parse(clean);
-}
-
-// For vendor_catalog (text columns): PostgreSQL array literal
-function toPgArray(arr) {
- if (!arr || !arr.length) return '{}';
- return '{' + arr.map(s => '"' + s.replace(/"/g, '\\"') + '"').join(',') + '}';
-}
-
-async function main() {
- const db = new Client({ connectionString: DB_URL });
- await db.connect();
- let success = 0, fail = 0;
-
- for (let i = 0; i < PRODUCTS.length; i++) {
- const p = PRODUCTS[i];
- console.log(`\n━━━ [${i+1}/9] arte ${p.sku} (${p.pattern} - ${p.color}) ━━━`);
- try {
- const imgBuf = await downloadImage(p.img);
- if (!imgBuf.length) throw new Error('Empty image');
- const b64 = imgBuf.toString('base64');
- console.log(` Downloaded: ${(imgBuf.length / 1024).toFixed(0)}KB`);
-
- const prompt = `You are an interior design wallcovering expert. Analyze this wallcovering image. The vendor color name is "${p.color}" and the pattern is "${p.pattern}".
-
-Return ONLY valid JSON with no markdown formatting:
-{
- "colors": ["list 3-6 actual colors visible in the wallcovering"],
- "backgroundColor": "the dominant background color",
- "tags": ["5-8 interior design tags like Luxury, Textured, Modern, Elegant"],
- "styles": ["1-3 design period styles like Contemporary, Art Deco, Mid-Century"],
- "patterns": ["1-3 pattern types like Geometric, Textured, Striped, Floral"],
- "description": "Two-sentence commercial description for an architectural wallcovering retailer. Never use the word wallpaper."
-}`;
-
- const resp = await callGemini(b64, 'image/jpeg', prompt);
- const text = resp?.candidates?.[0]?.content?.parts?.[0]?.text;
- if (!text) throw new Error('No Gemini text: ' + JSON.stringify(resp?.error || 'unknown').slice(0, 200));
-
- const data = parseGeminiJson(text);
- console.log(` Colors: ${data.colors?.join(', ')}`);
- console.log(` BG: ${data.backgroundColor}`);
- console.log(` Tags: ${data.tags?.join(', ')}`);
-
- // vendor_catalog uses TEXT columns (pg array format)
- await db.query(`UPDATE vendor_catalog SET ai_colors=$1, ai_background_color=$2, ai_tags=$3, ai_styles=$4, ai_patterns=$5, ai_description=$6 WHERE id=$7`,
- [toPgArray(data.colors), data.backgroundColor||'', toPgArray(data.tags), toPgArray(data.styles), toPgArray(data.patterns), data.description||'', p.id]);
-
- // arte_catalog uses JSONB columns (JSON format)
- await db.query(`UPDATE arte_catalog SET ai_colors=$1::jsonb, ai_background_color=$2, ai_tags=$3::jsonb, ai_styles=$4::jsonb, ai_patterns=$5::jsonb, ai_description=$6 WHERE arte_sku=$7`,
- [JSON.stringify(data.colors), data.backgroundColor||'', JSON.stringify(data.tags), JSON.stringify(data.styles), JSON.stringify(data.patterns), data.description||'', p.sku]);
-
- // Verify both
- const v1 = await db.query('SELECT ai_colors FROM vendor_catalog WHERE id=$1', [p.id]);
- const v2 = await db.query('SELECT ai_colors FROM arte_catalog WHERE arte_sku=$1', [p.sku]);
- const ok1 = v1.rows[0]?.ai_colors && v1.rows[0].ai_colors !== '{}';
- const ok2 = v2.rows[0]?.ai_colors && JSON.stringify(v2.rows[0].ai_colors) !== '[]';
- if (ok1 && ok2) {
- console.log(` ✅ Saved (vendor_catalog + arte_catalog)`); success++;
- } else {
- console.log(` ⚠️ Partial: vc=${ok1}, ac=${ok2}`); fail++;
- }
- } catch (err) {
- console.log(` ❌ Error: ${err.message}`); fail++;
- }
- await new Promise(r => setTimeout(r, 1200));
- }
-
- await db.end();
- console.log(`\n━━━━━━━━━━━━━━━━━━━━━━`);
- console.log(`RESULTS: ✅ ${success} ❌ ${fail} (of 9)`);
-}
-
-main().catch(e => { console.error(e); process.exit(1); });
diff --git a/enrich-arte-gaps.js.pre-scrub-2026-05-07.bak b/enrich-arte-gaps.js.pre-scrub-2026-05-07.bak
deleted file mode 100644
index 6f275bff..00000000
--- a/enrich-arte-gaps.js.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,124 +0,0 @@
-const https = require('https');
-const { Client } = require('pg');
-
-const GEMINI_KEY = 'REDACTED_GEMINI_KEY';
-const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`;
-const pgClient = new Client('postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified');
-
-const PROMPT = `You are an interior design wallcovering analyst. Analyze this wallcovering product image.
-
-Return ONLY valid JSON with these fields:
-{
- "colors": ["<list 3-6 colors visible, e.g. Gold, Cream, Charcoal>"],
- "backgroundColor": "<the single dominant background color>",
- "tags": ["<5-8 interior design tags, e.g. Luxury, Textured, Hospitality, Modern>"],
- "styles": ["<1-3 design styles, e.g. Contemporary, Art Deco, Minimalist>"],
- "patterns": ["<1-3 pattern types, e.g. Geometric, Solid, Striped, Textured>"],
- "description": "<2 sentence commercial description for architects and interior designers>"
-}
-
-Do NOT include markdown formatting. Return raw JSON only.`;
-
-const ARTE = [
- { sku:'19433', url:'https://edge.arte-international.com/media/a0686a22-83f6-46a5-86fe-b17566b88245/conversions/EssentialsLuxor_Horus_19433_Packshot_Web_LR-medium-two-thirds.jpg' },
- { sku:'23401', url:'https://edge.arte-international.com/media/d4610242-66e0-4b12-8723-df90ddb40e04/conversions/Memphis_Amarna_23401_Packshot_Web_HR-medium-two-thirds.jpg' },
- { sku:'23412', url:'https://edge.arte-international.com/media/6fe8a7ec-ecdd-49c4-b398-223293c48eed/conversions/Memphis_Sakkara_23412_Packshot_Web_HR-medium-two-thirds.jpg' },
- { sku:'23413', url:'https://edge.arte-international.com/media/a45badcf-d133-44b1-bcd6-1d74b4f13141/conversions/Memphis_Sakkara_23413_Packshot_Web_HR-medium-two-thirds.jpg' },
- { sku:'23421', url:'https://edge.arte-international.com/media/fb1738af-4e34-4d36-9a3e-7392f590381a/conversions/Memphis_Ibis_23421_Packshot_Web_LR-medium-two-thirds.jpg' },
- { sku:'23422', url:'https://edge.arte-international.com/media/2899330e-abba-481e-9098-ffd76f1731cf/conversions/Memphis_Ibis_23422_Packshot_Web_LR-medium-two-thirds.jpg' },
- { sku:'23431', url:'https://edge.arte-international.com/media/2ef17968-181d-4da5-8c8a-59f719a3c0fd/conversions/Memphis_Siwa_23431_Packshot_Web_LR-medium-two-thirds.jpg' },
- { sku:'23433', url:'https://edge.arte-international.com/media/fac5d958-0b0e-437f-abbe-a07a2c147676/conversions/Memphis_Siwa_23433_Packshot_Web_LR-medium-two-thirds.jpg' },
- { sku:'29221', url:'https://edge.arte-international.com/media/f92ad97a-a59b-431c-84ad-0a7520995f6a/conversions/Allures_Galon_29221_Flatshot_Web_LR-medium-two-thirds.jpg' },
-];
-
-function fetchB64(url) {
- return new Promise((resolve, reject) => {
- const opts = new URL(url);
- const req = https.get({
- hostname: opts.hostname, path: opts.pathname + opts.search,
- timeout: 15000,
- headers: {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
- 'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8',
- 'Referer': 'https://www.arte-international.com/',
- }
- }, (res) => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location)
- return fetchB64(res.headers.location).then(resolve).catch(reject);
- if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
- const c = []; res.on('data', d => c.push(d));
- res.on('end', () => resolve(Buffer.concat(c).toString('base64')));
- res.on('error', reject);
- });
- req.on('error', reject);
- });
-}
-
-function callGemini(b64) {
- const body = JSON.stringify({
- contents: [{ parts: [
- { text: PROMPT },
- { inline_data: { mime_type: 'image/jpeg', data: b64 } }
- ]}],
- generationConfig: { temperature: 0.2, maxOutputTokens: 1024 }
- });
- return new Promise((resolve, reject) => {
- const u = new URL(GEMINI_URL);
- const req = https.request({
- hostname: u.hostname, path: u.pathname + u.search,
- method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
- timeout: 30000,
- }, (res) => {
- const c = []; res.on('data', d => c.push(d));
- res.on('end', () => {
- try {
- const data = JSON.parse(Buffer.concat(c).toString());
- if (data.error) return reject(new Error(data.error.message));
- const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
- const clean = text.replace(/```json\n?/g,'').replace(/```\n?/g,'').trim();
- resolve(JSON.parse(clean));
- } catch(e) { reject(e); }
- });
- });
- req.on('error', reject); req.write(body); req.end();
- });
-}
-
-const sleep = ms => new Promise(r => setTimeout(r, ms));
-
-async function main() {
- await pgClient.connect();
- let ok = 0, fail = 0;
-
- for (const p of ARTE) {
- try {
- process.stdout.write(`Arte ${p.sku}... `);
- const b64 = await fetchB64(p.url);
- const ai = await callGemini(b64);
-
- // Update vendor_catalog
- await pgClient.query(`UPDATE vendor_catalog SET
- ai_colors=$1, ai_background_color=$2, ai_styles=$3,
- ai_patterns=$4, ai_tags=$5, ai_description=$6
- WHERE vendor_code='arte' AND mfr_sku=$7`,
- [JSON.stringify(ai.colors), ai.backgroundColor, JSON.stringify(ai.styles),
- JSON.stringify(ai.patterns), JSON.stringify(ai.tags), ai.description, p.sku]);
-
- // Update arte_catalog
- await pgClient.query(`UPDATE arte_catalog SET
- ai_colors=$1::jsonb, ai_background_color=$2, ai_styles=$3::jsonb,
- ai_patterns=$4::jsonb, ai_tags=$5::jsonb, ai_description=$6, ai_accepted_at=NOW()
- WHERE arte_sku=$7`,
- [JSON.stringify(ai.colors), ai.backgroundColor, JSON.stringify(ai.styles),
- JSON.stringify(ai.patterns), JSON.stringify(ai.tags), ai.description, p.sku]);
-
- console.log(`OK colors=[${ai.colors.join(', ')}] bg=${ai.backgroundColor}`);
- ok++; await sleep(1200);
- } catch(e) { console.log(`FAIL ${e.message}`); fail++; }
- }
-
- console.log(`\nDONE: ${ok}/9 Arte enriched, ${fail} failed`);
- await pgClient.end();
-}
-
-main().catch(e => { console.error(e); process.exit(1); });
diff --git a/enrich-arte-puppeteer.js.pre-scrub-2026-05-07.bak b/enrich-arte-puppeteer.js.pre-scrub-2026-05-07.bak
deleted file mode 100644
index c4c59c52..00000000
--- a/enrich-arte-puppeteer.js.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,164 +0,0 @@
-const puppeteer = require('puppeteer');
-const https = require('https');
-const { Client } = require('pg');
-
-const GEMINI_KEY = 'REDACTED_GEMINI_KEY';
-const GEMINI_MODEL = 'gemini-2.0-flash';
-const PG_URL = 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified';
-
-const ARTE_PRODUCTS = [
- { vc_id: 123, sku: '19433', name: 'Horus Desert Sun', url: 'https://www.arte-international.com/en/collections/essentials-luxor/horus/19433' },
- { vc_id: 16, sku: '23401', name: 'Amarna Bister', url: 'https://www.arte-international.com/en/collections/memphis/amarna/23401' },
- { vc_id: 15, sku: '23412', name: 'Sakkara Antique Gold', url: 'https://www.arte-international.com/en/collections/memphis/sakkara/23412' },
- { vc_id: 172, sku: '23413', name: 'Sakkara Off-white', url: 'https://www.arte-international.com/en/collections/memphis/sakkara/23413' },
- { vc_id: 72, sku: '23421', name: 'Ibis Biscuit', url: 'https://www.arte-international.com/en/collections/memphis/ibis/23421' },
- { vc_id: 75, sku: '23422', name: 'Ibis Chestnut', url: 'https://www.arte-international.com/en/collections/memphis/ibis/23422' },
- { vc_id: 78, sku: '23431', name: 'Siwa Bone', url: 'https://www.arte-international.com/en/collections/memphis/siwa/23431' },
- { vc_id: 83, sku: '23433', name: 'Siwa Biscuit', url: 'https://www.arte-international.com/en/collections/memphis/siwa/23433' },
- { vc_id: 183, sku: '29221', name: 'Galon Grey Linen', url: 'https://www.arte-international.com/en/collections/allures/galon/29221' },
-];
-
-const PROMPT = `You are an interior design color analyst for luxury wallcoverings.
-
-Analyze this wallcovering image and return a JSON object with these fields:
-- "colors": array of 3-6 prominent colors visible (e.g. ["Gold", "Ivory", "Charcoal"])
-- "backgroundColor": the single dominant background color (e.g. "Ivory")
-- "styles": array of 1-3 design styles (e.g. ["Contemporary", "Art Deco"])
-- "patterns": array of 1-2 pattern types (e.g. ["Geometric", "Textured"])
-- "tags": array of 4-8 interior design tags (e.g. ["luxury", "metallic", "hospitality", "accent wall"])
-- "description": a 2-sentence commercial description for an interior designer audience. NEVER use the word "wallpaper" — always say "wallcovering".
-
-Return ONLY the JSON object, no markdown fences, no explanation.`;
-
-function callGemini(imageBase64, mimeType) {
- return new Promise((resolve, reject) => {
- const body = JSON.stringify({
- contents: [{ parts: [
- { text: PROMPT },
- { inline_data: { mime_type: mimeType || 'image/jpeg', data: imageBase64 } }
- ]}],
- generationConfig: { temperature: 0.3, maxOutputTokens: 1024 }
- });
- const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`);
- const opts = { method: 'POST', hostname: url.hostname, path: url.pathname + url.search, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } };
- const req = https.request(opts, (res) => {
- const chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => {
- try {
- const data = JSON.parse(Buffer.concat(chunks).toString());
- if (data.error) return reject(new Error(data.error.message));
- let text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
- text = text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
- resolve(JSON.parse(text));
- } catch (e) { reject(e); }
- });
- });
- req.on('error', reject);
- req.write(body);
- req.end();
- });
-}
-
-function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
-
-async function main() {
- const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] });
- const db = new Client({ connectionString: PG_URL });
- await db.connect();
-
- let success = 0, failed = 0;
-
- for (const p of ARTE_PRODUCTS) {
- const page = await browser.newPage();
- try {
- console.log(`[arte] Navigating to ${p.sku} — ${p.name}...`);
- await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');
- await page.goto(p.url, { waitUntil: 'networkidle2', timeout: 30000 });
- await sleep(2000);
-
- // Find the packshot/product image on the page
- const imgSrc = await page.evaluate(() => {
- // Look for the main product image - Arte uses various selectors
- const selectors = [
- 'img[src*="Packshot"]',
- 'img[src*="packshot"]',
- 'img[src*="Flatshot"]',
- '.product-image img',
- '.product-detail img',
- '[class*="product"] img[src*="arte-international"]',
- 'img[src*="edge.arte-international.com"]'
- ];
- for (const sel of selectors) {
- const img = document.querySelector(sel);
- if (img && img.src) return img.src;
- }
- // Fallback: find the largest image on page with arte domain
- const allImgs = Array.from(document.querySelectorAll('img[src*="arte-international"]'));
- if (allImgs.length) {
- return allImgs.sort((a, b) => (b.naturalWidth || 0) - (a.naturalWidth || 0))[0]?.src;
- }
- return null;
- });
-
- if (!imgSrc) {
- // Fallback: take a screenshot of the product area
- console.log(` No image found, taking page screenshot...`);
- const screenshot = await page.screenshot({ type: 'jpeg', quality: 85 });
- const ai = await callGemini(screenshot.toString('base64'), 'image/jpeg');
- await saveToDb(db, p, ai);
- success++;
- console.log(` ✅ (screenshot) Colors: ${(ai.colors||[]).join(', ')}`);
- } else {
- console.log(` Found image: ${imgSrc.substring(0, 80)}...`);
- // Fetch the image through puppeteer's page context (inherits cookies/referer)
- const imgB64 = await page.evaluate(async (src) => {
- const resp = await fetch(src);
- const blob = await resp.blob();
- return new Promise((resolve) => {
- const reader = new FileReader();
- reader.onloadend = () => resolve(reader.result.split(',')[1]);
- reader.readAsDataURL(blob);
- });
- }, imgSrc);
-
- const ai = await callGemini(imgB64, 'image/jpeg');
- await saveToDb(db, p, ai);
- success++;
- console.log(` ✅ Colors: ${(ai.colors||[]).join(', ')} | BG: ${ai.backgroundColor}`);
- }
- await sleep(1500);
- } catch (err) {
- console.error(` ❌ FAILED ${p.sku}: ${err.message}`);
- failed++;
- } finally {
- await page.close();
- }
- }
-
- await browser.close();
- await db.end();
- console.log(`\nDone: ${success} enriched, ${failed} failed out of ${ARTE_PRODUCTS.length}`);
-}
-
-async function saveToDb(db, p, ai) {
- const colorsStr = (ai.colors || []).join(', ');
- const stylesStr = (ai.styles || []).join(', ');
- const patternsStr = (ai.patterns || []).join(', ');
- const tagsStr = (ai.tags || []).join(', ');
-
- await db.query(`UPDATE vendor_catalog SET
- ai_colors = $1, ai_background_color = $2, ai_styles = $3,
- ai_patterns = $4, ai_tags = $5, ai_description = $6
- WHERE id = $7`,
- [colorsStr, ai.backgroundColor, stylesStr, patternsStr, tagsStr, ai.description, p.vc_id]);
-
- await db.query(`UPDATE arte_catalog SET
- ai_colors = $1, ai_background_color = $2, ai_styles = $3,
- ai_patterns = $4, ai_tags = $5, ai_description = $6, ai_accepted_at = NOW()
- WHERE arte_sku = $7`,
- [JSON.stringify(ai.colors), ai.backgroundColor, JSON.stringify(ai.styles),
- JSON.stringify(ai.patterns), JSON.stringify(ai.tags), ai.description, p.sku]);
-}
-
-main().catch(console.error);
diff --git a/enrich-remaining-13.js.pre-scrub-2026-05-07.bak b/enrich-remaining-13.js.pre-scrub-2026-05-07.bak
deleted file mode 100644
index ac73925a..00000000
--- a/enrich-remaining-13.js.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,136 +0,0 @@
-const https = require('https');
-const http = require('http');
-const { Client } = require('pg');
-
-const GEMINI_KEY = 'REDACTED_GEMINI_KEY';
-const GEMINI_MODEL = 'gemini-2.0-flash';
-const PG_URL = 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified';
-
-const PRODUCTS = [
- // Arte — use packshot_url from arte_catalog
- { vc_id: 123, vendor: 'arte', sku: '19433', name: 'Horus Desert Sun', img: 'https://edge.arte-international.com/media/a0686a22-83f6-46a5-86fe-b17566b88245/conversions/EssentialsLuxor_Horus_19433_Packshot_Web_LR-medium-two-thirds.jpg' },
- { vc_id: 16, vendor: 'arte', sku: '23401', name: 'Amarna Bister', img: 'https://edge.arte-international.com/media/d4610242-66e0-4b12-8723-df90ddb40e04/conversions/Memphis_Amarna_23401_Packshot_Web_HR-medium-two-thirds.jpg' },
- { vc_id: 15, vendor: 'arte', sku: '23412', name: 'Sakkara Antique Gold', img: 'https://edge.arte-international.com/media/6fe8a7ec-ecdd-49c4-b398-223293c48eed/conversions/Memphis_Sakkara_23412_Packshot_Web_HR-medium-two-thirds.jpg' },
- { vc_id: 172, vendor: 'arte', sku: '23413', name: 'Sakkara Off-white', img: 'https://edge.arte-international.com/media/a45badcf-d133-44b1-bcd6-1d74b4f13141/conversions/Memphis_Sakkara_23413_Packshot_Web_HR-medium-two-thirds.jpg' },
- { vc_id: 72, vendor: 'arte', sku: '23421', name: 'Ibis Biscuit', img: 'https://edge.arte-international.com/media/fb1738af-4e34-4d36-9a3e-7392f590381a/conversions/Memphis_Ibis_23421_Packshot_Web_LR-medium-two-thirds.jpg' },
- { vc_id: 75, vendor: 'arte', sku: '23422', name: 'Ibis Chestnut', img: 'https://edge.arte-international.com/media/2899330e-abba-481e-9098-ffd76f1731cf/conversions/Memphis_Ibis_23422_Packshot_Web_LR-medium-two-thirds.jpg' },
- { vc_id: 78, vendor: 'arte', sku: '23431', name: 'Siwa Bone', img: 'https://edge.arte-international.com/media/2ef17968-181d-4da5-8c8a-59f719a3c0fd/conversions/Memphis_Siwa_23431_Packshot_Web_LR-medium-two-thirds.jpg' },
- { vc_id: 83, vendor: 'arte', sku: '23433', name: 'Siwa Biscuit', img: 'https://edge.arte-international.com/media/fac5d958-0b0e-437f-abbe-a07a2c147676/conversions/Memphis_Siwa_23433_Packshot_Web_LR-medium-two-thirds.jpg' },
- { vc_id: 183, vendor: 'arte', sku: '29221', name: 'Galon Grey Linen', img: 'https://edge.arte-international.com/media/f92ad97a-a59b-431c-84ad-0a7520995f6a/conversions/Allures_Galon_29221_Flatshot_Web_LR-medium-two-thirds.jpg' },
- // Black Edition — use vendor_catalog image_url
- { vc_id: 1751, vendor: 'black_edition', sku: 'W924/01', name: 'Mizumi Panel Carbon', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-01FP-mizumi-panel-charcoal_00.jpg' },
- { vc_id: 1752, vendor: 'black_edition', sku: 'W924/02', name: 'Mizumi Panel Midnight', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-02FP-mizumi-panel-midnight_00.jpg' },
- { vc_id: 1753, vendor: 'black_edition', sku: 'W924/03', name: 'Mizumi Panel Basalt', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-03FP-mizumi-panel-stone_00.jpg' },
- { vc_id: 1754, vendor: 'black_edition', sku: 'W924/04', name: 'Mizumi Panel Viridian', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-04FP-mizumi-panel-teal_02.jpg' },
-];
-
-const PROMPT = `You are an interior design color analyst for luxury wallcoverings.
-
-Analyze this wallcovering image and return a JSON object with these fields:
-- "colors": array of 3-6 prominent colors visible (e.g. ["Gold", "Ivory", "Charcoal"])
-- "backgroundColor": the single dominant background color (e.g. "Ivory")
-- "styles": array of 1-3 design styles (e.g. ["Contemporary", "Art Deco"])
-- "patterns": array of 1-2 pattern types (e.g. ["Geometric", "Textured"])
-- "tags": array of 4-8 interior design tags (e.g. ["luxury", "metallic", "hospitality", "accent wall"])
-- "description": a 2-sentence commercial description for an interior designer audience. NEVER use the word "wallpaper" — always say "wallcovering".
-
-Return ONLY the JSON object, no markdown fences, no explanation.`;
-
-function fetchImageBase64(url) {
- return new Promise((resolve, reject) => {
- const mod = url.startsWith('https') ? https : http;
- mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- return fetchImageBase64(res.headers.location).then(resolve).catch(reject);
- }
- if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
- const chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => resolve(Buffer.concat(chunks).toString('base64')));
- res.on('error', reject);
- }).on('error', reject);
- });
-}
-
-function callGemini(imageBase64, mimeType) {
- return new Promise((resolve, reject) => {
- const body = JSON.stringify({
- contents: [{
- parts: [
- { text: PROMPT },
- { inline_data: { mime_type: mimeType || 'image/jpeg', data: imageBase64 } }
- ]
- }],
- generationConfig: { temperature: 0.3, maxOutputTokens: 1024 }
- });
- const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`);
- const opts = { method: 'POST', hostname: url.hostname, path: url.pathname + url.search, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } };
- const req = https.request(opts, (res) => {
- const chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => {
- try {
- const data = JSON.parse(Buffer.concat(chunks).toString());
- if (data.error) return reject(new Error(data.error.message));
- let text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
- text = text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
- resolve(JSON.parse(text));
- } catch (e) { reject(e); }
- });
- });
- req.on('error', reject);
- req.write(body);
- req.end();
- });
-}
-
-function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
-
-async function main() {
- const db = new Client({ connectionString: PG_URL });
- await db.connect();
-
- let success = 0, failed = 0;
-
- for (const p of PRODUCTS) {
- try {
- console.log(`[${p.vendor}] Analyzing ${p.sku} — ${p.name}...`);
- const imgB64 = await fetchImageBase64(p.img);
- const ai = await callGemini(imgB64);
-
- const colorsStr = (ai.colors || []).join(', ');
- const stylesStr = (ai.styles || []).join(', ');
- const patternsStr = (ai.patterns || []).join(', ');
- const tagsStr = (ai.tags || []).join(', ');
-
- // Update vendor_catalog
- await db.query(`UPDATE vendor_catalog SET
- ai_colors = $1, ai_background_color = $2, ai_styles = $3,
- ai_patterns = $4, ai_tags = $5, ai_description = $6
- WHERE id = $7`,
- [colorsStr, ai.backgroundColor, stylesStr, patternsStr, tagsStr, ai.description, p.vc_id]);
-
- // Also update arte_catalog if Arte
- if (p.vendor === 'arte') {
- await db.query(`UPDATE arte_catalog SET
- ai_colors = $1, ai_background_color = $2, ai_styles = $3,
- ai_patterns = $4, ai_tags = $5, ai_description = $6, ai_accepted_at = NOW()
- WHERE arte_sku = $7`,
- [JSON.stringify(ai.colors), ai.backgroundColor, JSON.stringify(ai.styles),
- JSON.stringify(ai.patterns), JSON.stringify(ai.tags), ai.description, p.sku]);
- }
-
- console.log(` ✅ Colors: ${colorsStr} | BG: ${ai.backgroundColor}`);
- success++;
- await sleep(1200); // rate limit
- } catch (err) {
- console.error(` ❌ FAILED ${p.sku}: ${err.message}`);
- failed++;
- }
- }
-
- await db.end();
- console.log(`\nDone: ${success} enriched, ${failed} failed out of ${PRODUCTS.length}`);
-}
-
-main().catch(console.error);
diff --git a/gemini-classify-texture.js.pre-scrub-2026-05-07.bak b/gemini-classify-texture.js.pre-scrub-2026-05-07.bak
deleted file mode 100644
index 6d0f7fab..00000000
--- a/gemini-classify-texture.js.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,112 +0,0 @@
-#!/usr/bin/env node
-const { Client } = require('pg');
-const https = require('https');
-const http = require('http');
-
-const GEMINI_KEY = 'REDACTED_GEMINI_KEY';
-const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`;
-const PROMPT = `Is this wallcovering image a repeating pattern or a non-repeating texture? If it's a texture (grasscloth, linen, plain, solid, stucco, metallic, stone, concrete, weave, etc.), respond 'TEXTURE'. If it has a visible repeating pattern (florals, stripes, damask, geometric, medallion, toile, scenic, mural, etc.), respond 'PATTERN'. Respond with only one word: TEXTURE or PATTERN.`;
-
-const db = new Client({ connectionString: 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified' });
-const products = require('/tmp/thibaut_remaining.json');
-const stats = { texture: 0, pattern: 0, error: 0, total: products.length };
-
-function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
-
-function downloadImage(url) {
- return new Promise((resolve, reject) => {
- const request = (targetUrl, redirectCount = 0) => {
- if (redirectCount > 5) return reject(new Error('Too many redirects'));
- const client = targetUrl.startsWith('https') ? https : http;
- client.get(targetUrl, { timeout: 15000 }, (res) => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- return request(res.headers.location, redirectCount + 1);
- }
- if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); }
- const chunks = [];
- res.on('data', chunk => chunks.push(chunk));
- res.on('end', () => {
- const buf = Buffer.concat(chunks);
- const ct = res.headers['content-type'] || 'image/jpeg';
- resolve({ base64: buf.toString('base64'), mimeType: ct.split(';')[0].trim() });
- });
- res.on('error', reject);
- }).on('error', reject).on('timeout', function() { this.destroy(); reject(new Error('timeout')); });
- };
- request(url);
- });
-}
-
-function classifyWithGemini(base64, mimeType) {
- const payload = {
- contents: [{ parts: [
- { text: PROMPT },
- { inline_data: { mime_type: mimeType, data: base64 } }
- ]}],
- generationConfig: { temperature: 0.1, maxOutputTokens: 10 }
- };
- return new Promise((resolve, reject) => {
- const url = new URL(GEMINI_URL);
- const postData = JSON.stringify(payload);
- const req = https.request({
- hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },
- timeout: 30000
- }, (res) => {
- let data = '';
- res.on('data', chunk => data += chunk);
- res.on('end', () => {
- try {
- const json = JSON.parse(data);
- if (json.error) return reject(new Error(json.error.message));
- const text = json.candidates?.[0]?.content?.parts?.[0]?.text?.trim().toUpperCase() || '';
- if (text.includes('TEXTURE')) resolve('TEXTURE');
- else if (text.includes('PATTERN')) resolve('PATTERN');
- else resolve('UNKNOWN');
- } catch (e) { reject(e); }
- });
- });
- req.on('error', reject);
- req.on('timeout', () => { req.destroy(); reject(new Error('gemini timeout')); });
- req.write(postData); req.end();
- });
-}
-
-async function run() {
- await db.connect();
- console.log(`Classifying ${products.length} thibaut products with Gemini Vision (base64)...`);
- const startTime = Date.now();
-
- for (let i = 0; i < products.length; i++) {
- const p = products[i];
- try {
- const { base64, mimeType } = await downloadImage(p.image_url);
- const result = await classifyWithGemini(base64, mimeType);
-
- if (result === 'TEXTURE') {
- await db.query(`UPDATE thibaut_catalog SET repeat_h = '0', match_type = COALESCE(match_type, 'Texture'), updated_at = NOW() WHERE id = $1`, [p.id]);
- stats.texture++;
- } else if (result === 'PATTERN') {
- await db.query(`UPDATE thibaut_catalog SET match_type = COALESCE(match_type, 'Unknown Pattern'), updated_at = NOW() WHERE id = $1`, [p.id]);
- stats.pattern++;
- } else { stats.error++; }
-
- const pct = ((i + 1) / products.length * 100).toFixed(0);
- if ((i + 1) % 5 === 0 || i === products.length - 1)
- console.log(` [${pct}%] ${i + 1}/${products.length} — T:${stats.texture} P:${stats.pattern} E:${stats.error} | ${p.pattern_name} → ${result}`);
- await sleep(200);
- } catch (err) {
- stats.error++;
- console.log(` [ERR] #${p.id} ${p.pattern_name}: ${err.message.substring(0, 80)}`);
- await sleep(500);
- }
- }
-
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
- console.log(`\n=== GEMINI CLASSIFICATION COMPLETE (${elapsed}s) ===`);
- console.log(`Total: ${stats.total} | Texture: ${stats.texture} | Pattern: ${stats.pattern} | Errors: ${stats.error}`);
- await db.end();
- require('fs').writeFileSync('/tmp/gemini_classify_stats.json', JSON.stringify(stats));
-}
-
-run().catch(err => { console.error('Fatal:', err); process.exit(1); });
diff --git a/gemini-enrich-arte.js.pre-scrub-2026-05-07.bak b/gemini-enrich-arte.js.pre-scrub-2026-05-07.bak
deleted file mode 100644
index 3c281f6b..00000000
--- a/gemini-enrich-arte.js.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,120 +0,0 @@
-const https = require('https');
-const { Client } = require('pg');
-
-const GEMINI_KEY = 'REDACTED_GEMINI_KEY';
-const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`;
-
-const products = [
- { id: 123, sku: '19433', name: 'Horus Desert Sun', img: 'https://edge.arte-international.com/media/4960d9f1-bc0a-4bcb-9a82-c09d9e5e7d98/conversions/EssentialsLuxor_Horus_19436_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 16, sku: '23401', name: 'Amarna Bister', img: 'https://edge.arte-international.com/media/cc56d30c-5e1c-4b22-878e-b437681e6f1b/conversions/Memphis_Amarna_23400_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 15, sku: '23412', name: 'Sakkara Antique Gold', img: 'https://edge.arte-international.com/media/a580214f-25ff-472e-b88c-6a2bd8dfab64/conversions/Memphis_Sakkara_23410_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 172, sku: '23413', name: 'Sakkara Off-white', img: 'https://edge.arte-international.com/media/a580214f-25ff-472e-b88c-6a2bd8dfab64/conversions/Memphis_Sakkara_23410_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 72, sku: '23421', name: 'Ibis Biscuit', img: 'https://edge.arte-international.com/media/2183e2bc-0620-43c4-81ae-6364abed3937/conversions/Memphis_Ibis_23420_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 75, sku: '23422', name: 'Ibis Chestnut', img: 'https://edge.arte-international.com/media/2183e2bc-0620-43c4-81ae-6364abed3937/conversions/Memphis_Ibis_23420_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 78, sku: '23431', name: 'Siwa Bone', img: 'https://edge.arte-international.com/media/428898de-d288-40f3-b463-1714c59de939/conversions/Memphis_Siwa_23432_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 83, sku: '23433', name: 'Siwa Biscuit', img: 'https://edge.arte-international.com/media/428898de-d288-40f3-b463-1714c59de939/conversions/Memphis_Siwa_23432_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 183, sku: '29221', name: 'Galon Grey Linen', img: 'https://edge.arte-international.com/media/8571da7a-7bf9-4abc-adf0-76863cee5413/conversions/Allures_Galon9224_1_Roomshot_Web_LR-medium-two-thirds.jpg' },
-];
-
-function downloadImage(url) {
- return new Promise((resolve, reject) => {
- const get = (u, redirects = 0) => {
- if (redirects > 5) return reject(new Error('Too many redirects'));
- https.get(u, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- return get(res.headers.location, redirects + 1);
- }
- if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
- const chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => resolve(Buffer.concat(chunks)));
- }).on('error', reject);
- };
- get(url);
- });
-}
-
-function callGemini(body) {
- return new Promise((resolve, reject) => {
- const data = JSON.stringify(body);
- const req = https.request(GEMINI_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } }, res => {
- let buf = '';
- res.on('data', c => buf += c);
- res.on('end', () => { try { resolve(JSON.parse(buf)); } catch(e) { reject(new Error(buf.slice(0,500))); } });
- });
- req.on('error', reject);
- req.write(data);
- req.end();
- });
-}
-
-const PROMPT = `You are an interior design wallcovering analyst. Analyze this wallcovering image and return ONLY valid JSON (no markdown, no backticks):
-{
- "colors": ["<list 3-6 dominant colors you actually see>"],
- "backgroundColor": "<the single dominant background color>",
- "styles": ["<1-3 design styles>"],
- "patterns": ["<1-3 pattern types>"],
- "tags": ["<5-10 interior design tags>"],
- "description": "<2-sentence commercial description>"
-}
-The product is: PRODUCT_NAME. Focus on what you actually see.`;
-
-async function analyzeOne(product) {
- console.log(` Downloading image...`);
- const imgBuf = await downloadImage(product.img);
- console.log(` Downloaded ${(imgBuf.length/1024).toFixed(0)}KB, calling Gemini...`);
- const b64 = imgBuf.toString('base64');
-
- const body = {
- contents: [{
- parts: [
- { text: PROMPT.replace('PRODUCT_NAME', product.name) },
- { inlineData: { mimeType: 'image/jpeg', data: b64 } }
- ]
- }],
- generationConfig: { temperature: 0.2 }
- };
-
- const res = await callGemini(body);
- if (res.error) throw new Error(res.error.message);
- const text = res.candidates?.[0]?.content?.parts?.[0]?.text || '';
- return JSON.parse(text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim());
-}
-
-async function main() {
- const client = new Client({ connectionString: 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified' });
- await client.connect();
- console.log('Processing 9 Arte products with base64 image download...\n');
-
- let success = 0, failed = 0;
-
- for (const p of products) {
- console.log(`[${success+failed+1}/9] ${p.sku} (${p.name})`);
- try {
- const a = await analyzeOne(p);
- const colors = a.colors?.join(', ') || '';
- const bg = a.backgroundColor || '';
- const styles = a.styles?.join(', ') || '';
- const patterns = a.patterns?.join(', ') || '';
- const tags = a.tags?.join(', ') || '';
- const desc = a.description || '';
-
- await client.query(`UPDATE vendor_catalog SET ai_colors=$1, ai_background_color=$2, ai_styles=$3, ai_patterns=$4, ai_tags=$5, ai_description=$6 WHERE id=$7`,
- [colors, bg, styles, patterns, tags, desc, p.id]);
- await client.query(`UPDATE arte_catalog SET ai_colors=$1, ai_background_color=$2, ai_styles=$3, ai_patterns=$4, ai_tags=$5, ai_description=$6 WHERE arte_sku=$7`,
- [colors, bg, styles, patterns, tags, desc, p.sku]);
-
- console.log(` ✅ Colors: ${colors}`);
- success++;
- } catch(e) {
- console.log(` ❌ ${e.message}`);
- failed++;
- }
- await new Promise(r => setTimeout(r, 300));
- }
-
- console.log(`\nDone: ${success} succeeded, ${failed} failed`);
- await client.end();
-}
-
-main().catch(e => { console.error(e); process.exit(1); });
diff --git a/gemini-enrich-arte9.js.pre-scrub-2026-05-07.bak b/gemini-enrich-arte9.js.pre-scrub-2026-05-07.bak
deleted file mode 100644
index 40f41186..00000000
--- a/gemini-enrich-arte9.js.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,152 +0,0 @@
-const https = require('https');
-const { Client } = require('pg');
-
-const GEMINI_KEY = 'REDACTED_GEMINI_KEY';
-const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`;
-
-const db = new Client({ connectionString: 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified' });
-
-const PROMPT = `You are an interior design wallcovering analyst. Analyze this wallcovering product image (it may be a room setting showing the wallcovering installed on walls).
-Focus on the WALLCOVERING itself, not furniture or decor.
-Return ONLY valid JSON with these fields:
-{
- "colors": ["<list of 3-6 dominant colors in the wallcovering, e.g. Gold, Cream, Charcoal>"],
- "backgroundColor": "<the single dominant background color of the wallcovering>",
- "tags": ["<5-8 interior design tags, e.g. Luxury, Textured, Organic, Hospitality>"],
- "styles": ["<1-3 design style periods, e.g. Contemporary, Art Deco, Mid-Century Modern>"],
- "patterns": ["<1-3 pattern types, e.g. Geometric, Abstract, Textural, Striped>"],
- "description": "<2 sentence commercial description for an architect or interior designer>"
-}
-No markdown, no code fences, just raw JSON.`;
-
-// Use vendor_catalog room shot URLs (confirmed working 200 OK)
-const products = [
- { id: 123, sku: '19433', name: 'Horus Desert Sun', img: 'https://edge.arte-international.com/media/4960d9f1-bc0a-4bcb-9a82-c09d9e5e7d98/conversions/EssentialsLuxor_Horus_19436_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 16, sku: '23401', name: 'Amarna Bister', img: 'https://edge.arte-international.com/media/cc56d30c-5e1c-4b22-878e-b437681e6f1b/conversions/Memphis_Amarna_23400_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 15, sku: '23412', name: 'Sakkara Antique Gold', img: 'https://edge.arte-international.com/media/a580214f-25ff-472e-b88c-6a2bd8dfab64/conversions/Memphis_Sakkara_23410_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 172, sku: '23413', name: 'Sakkara Off-white', img: 'https://edge.arte-international.com/media/a580214f-25ff-472e-b88c-6a2bd8dfab64/conversions/Memphis_Sakkara_23410_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 72, sku: '23421', name: 'Ibis Biscuit', img: 'https://edge.arte-international.com/media/2183e2bc-0620-43c4-81ae-6364abed3937/conversions/Memphis_Ibis_23420_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 75, sku: '23422', name: 'Ibis Chestnut', img: 'https://edge.arte-international.com/media/2183e2bc-0620-43c4-81ae-6364abed3937/conversions/Memphis_Ibis_23420_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 78, sku: '23431', name: 'Siwa Bone', img: 'https://edge.arte-international.com/media/428898de-d288-40f3-b463-1714c59de939/conversions/Memphis_Siwa_23432_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 83, sku: '23433', name: 'Siwa Biscuit', img: 'https://edge.arte-international.com/media/428898de-d288-40f3-b463-1714c59de939/conversions/Memphis_Siwa_23432_Roomshot_Web_LR-medium-two-thirds.jpg' },
- { id: 183, sku: '29221', name: 'Galon Grey Linen', img: 'https://edge.arte-international.com/media/8571da7a-7bf9-4abc-adf0-76863cee5413/conversions/Allures_Galon9224_1_Roomshot_Web_LR-medium-two-thirds.jpg' },
-];
-
-function fetchImageAsBase64(url) {
- return new Promise((resolve, reject) => {
- https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- return fetchImageAsBase64(res.headers.location).then(resolve).catch(reject);
- }
- if (res.statusCode !== 200) {
- return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
- }
- const chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => {
- const buf = Buffer.concat(chunks);
- if (buf.length < 1000) return reject(new Error(`Image too small: ${buf.length} bytes`));
- resolve(buf.toString('base64'));
- });
- res.on('error', reject);
- }).on('error', reject);
- });
-}
-
-function callGemini(b64, productName) {
- return new Promise((resolve, reject) => {
- const payload = JSON.stringify({
- contents: [{
- parts: [
- { text: `Product: ${productName}\n\n${PROMPT}` },
- { inline_data: { mime_type: 'image/jpeg', data: b64 } }
- ]
- }]
- });
-
- const url = new URL(GEMINI_URL);
- const options = {
- hostname: url.hostname,
- path: url.pathname + url.search,
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- };
-
- const req = https.request(options, res => {
- let data = '';
- res.on('data', chunk => data += chunk);
- res.on('end', () => {
- try {
- const json = JSON.parse(data);
- if (json.error) return reject(new Error(json.error.message));
- const text = json.candidates?.[0]?.content?.parts?.[0]?.text || '';
- const cleaned = text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
- resolve(JSON.parse(cleaned));
- } catch (e) {
- reject(new Error(`Parse: ${e.message} — ${data.substring(0, 200)}`));
- }
- });
- });
- req.on('error', reject);
- req.write(payload);
- req.end();
- });
-}
-
-async function updateDB(product, analysis) {
- // vendor_catalog uses text columns — postgres array literal
- const pgArr = arr => `{${arr.map(c => `"${c.replace(/"/g, '')}"`).join(',')}}`;
- const vc_colors = pgArr(analysis.colors || []);
- const vc_tags = pgArr(analysis.tags || []);
- const vc_styles = pgArr(analysis.styles || []);
- const vc_patterns = pgArr(analysis.patterns || []);
-
- // arte_catalog uses jsonb columns — JSON array
- const ac_colors = JSON.stringify(analysis.colors || []);
- const ac_tags = JSON.stringify(analysis.tags || []);
- const ac_styles = JSON.stringify(analysis.styles || []);
- const ac_patterns = JSON.stringify(analysis.patterns || []);
-
- // Update vendor_catalog (text columns)
- await db.query(`
- UPDATE vendor_catalog SET
- ai_colors = $1, ai_background_color = $2, ai_tags = $3,
- ai_styles = $4, ai_patterns = $5, ai_description = $6
- WHERE id = $7
- `, [vc_colors, analysis.backgroundColor, vc_tags, vc_styles, vc_patterns, analysis.description, product.id]);
-
- // Update arte_catalog (jsonb columns)
- await db.query(`
- UPDATE arte_catalog SET
- ai_colors = $1::jsonb, ai_background_color = $2, ai_tags = $3::jsonb,
- ai_styles = $4::jsonb, ai_patterns = $5::jsonb, ai_description = $6
- WHERE arte_sku = $7
- `, [ac_colors, analysis.backgroundColor, ac_tags, ac_styles, ac_patterns, analysis.description, product.sku]);
-}
-
-async function main() {
- await db.connect();
- console.log('Connected. Processing 9 Arte products...\n');
- let ok = 0, fail = 0;
-
- for (const p of products) {
- try {
- process.stdout.write(`${p.sku} ${p.name}... `);
- const b64 = await fetchImageAsBase64(p.img);
- console.log(`img ${Math.round(b64.length/1024)}KB`);
- const analysis = await callGemini(b64, p.name);
- console.log(` ✅ ${analysis.colors.join(', ')} | bg: ${analysis.backgroundColor}`);
- await updateDB(p, analysis);
- ok++;
- await new Promise(r => setTimeout(r, 1000));
- } catch (e) {
- console.log(` ❌ ${e.message}`);
- fail++;
- }
- }
-
- console.log(`\nDone: ${ok}/9 succeeded, ${fail} failed`);
- await db.end();
-}
-
-main().catch(e => { console.error(e); process.exit(1); });
diff --git a/gemini-enrich-gaps.js.pre-scrub-2026-05-07.bak b/gemini-enrich-gaps.js.pre-scrub-2026-05-07.bak
deleted file mode 100644
index e43977c3..00000000
--- a/gemini-enrich-gaps.js.pre-scrub-2026-05-07.bak
+++ /dev/null
@@ -1,175 +0,0 @@
-const https = require('https');
-const http = require('http');
-const { Client } = require('pg');
-
-const GEMINI_KEY = 'REDACTED_GEMINI_KEY';
-const GEMINI_MODEL = 'gemini-2.0-flash';
-const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`;
-
-const DB = 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified';
-
-// Products to process — using arte_catalog packshot images for Arte, vendor_catalog images for Black Edition
-const products = [
- // Arte (9) — packshot images from arte_catalog
- { id: 123, vendor: 'arte', sku: '19433', name: 'Horus Desert Sun', img: 'https://edge.arte-international.com/media/a0686a22-83f6-46a5-86fe-b17566b88245/conversions/EssentialsLuxor_Horus_19433_Packshot_Web_LR-thumb-two-thirds.jpg' },
- { id: 16, vendor: 'arte', sku: '23401', name: 'Amarna Bister', img: 'https://edge.arte-international.com/media/d4610242-66e0-4b12-8723-df90ddb40e04/conversions/Memphis_Amarna_23401_Packshot_Web_HR-thumb-two-thirds.jpg' },
- { id: 15, vendor: 'arte', sku: '23412', name: 'Sakkara Antique Gold', img: 'https://edge.arte-international.com/media/6fe8a7ec-ecdd-49c4-b398-223293c48eed/conversions/Memphis_Sakkara_23412_Packshot_Web_HR-thumb-two-thirds.jpg' },
- { id: 172, vendor: 'arte', sku: '23413', name: 'Sakkara Off-white', img: 'https://edge.arte-international.com/media/a45badcf-d133-44b1-bcd6-1d74b4f13141/conversions/Memphis_Sakkara_23413_Packshot_Web_HR-thumb-two-thirds.jpg' },
- { id: 72, vendor: 'arte', sku: '23421', name: 'Ibis Biscuit', img: 'https://edge.arte-international.com/media/fb1738af-4e34-4d36-9a3e-7392f590381a/conversions/Memphis_Ibis_23421_Packshot_Web_LR-thumb-two-thirds.jpg' },
- { id: 75, vendor: 'arte', sku: '23422', name: 'Ibis Chestnut', img: 'https://edge.arte-international.com/media/2899330e-abba-481e-9098-ffd76f1731cf/conversions/Memphis_Ibis_23422_Packshot_Web_LR-thumb-two-thirds.jpg' },
- { id: 78, vendor: 'arte', sku: '23431', name: 'Siwa Bone', img: 'https://edge.arte-international.com/media/2ef17968-181d-4da5-8c8a-59f719a3c0fd/conversions/Memphis_Siwa_23431_Packshot_Web_LR-thumb-two-thirds.jpg' },
- { id: 83, vendor: 'arte', sku: '23433', name: 'Siwa Biscuit', img: 'https://edge.arte-international.com/media/fac5d958-0b0e-437f-abbe-a07a2c147676/conversions/Memphis_Siwa_23433_Packshot_Web_LR-thumb-two-thirds.jpg' },
- { id: 183, vendor: 'arte', sku: '29221', name: 'Galon Grey Linen', img: 'https://edge.arte-international.com/media/f92ad97a-a59b-431c-84ad-0a7520995f6a/conversions/Allures_Galon_29221_Flatshot_Web_LR-thumb-two-thirds.jpg' },
- // Black Edition (4)
- { id: 1751, vendor: 'black_edition', sku: 'W924/01', name: 'Mizumi Panel Carbon', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-01FP-mizumi-panel-charcoal_00.jpg' },
- { id: 1752, vendor: 'black_edition', sku: 'W924/02', name: 'Mizumi Panel Midnight', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-02FP-mizumi-panel-midnight_00.jpg' },
- { id: 1753, vendor: 'black_edition', sku: 'W924/03', name: 'Mizumi Panel Basalt', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-03FP-mizumi-panel-stone_00.jpg' },
- { id: 1754, vendor: 'black_edition', sku: 'W924/04', name: 'Mizumi Panel Viridian', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-04FP-mizumi-panel-teal_02.jpg' },
-];
-
-const PROMPT = `You are an interior design wallcovering analyst. Analyze this wallcovering product image and return ONLY a JSON object (no markdown, no code fences) with these fields:
-
-{
- "colors": ["<list 3-6 dominant colors you actually see, e.g. Warm Gold, Ivory, Charcoal>"],
- "backgroundColor": "<the single most dominant background color>",
- "styles": ["<1-3 design styles, e.g. Contemporary, Art Deco, Mid-Century Modern>"],
- "patterns": ["<1-3 pattern types, e.g. Geometric, Textured, Floral, Abstract>"],
- "tags": ["<5-10 interior design tags for search, e.g. luxury, metallic, neutral, organic, minimalist>"],
- "description": "<2 sentences describing the wallcovering for a commercial buyer>"
-}
-
-Product: PRODUCT_NAME
-Return ONLY the JSON object.`;
-
-function fetchJSON(url, body) {
- return new Promise((resolve, reject) => {
- const data = JSON.stringify(body);
- const parsed = new URL(url);
- const req = https.request({
- hostname: parsed.hostname,
- path: parsed.pathname + parsed.search,
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
- }, (res) => {
- let chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => {
- try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
- catch(e) { reject(new Error('JSON parse error: ' + Buffer.concat(chunks).toString().slice(0, 500))); }
- });
- });
- req.on('error', reject);
- req.write(data);
- req.end();
- });
-}
-
-async function analyzeWithGemini(product) {
- const prompt = PROMPT.replace('PRODUCT_NAME', product.name);
- const body = {
- contents: [{
- parts: [
- { text: prompt },
- { inlineData: undefined },
- { fileData: undefined }
- ]
- }],
- generationConfig: { temperature: 0.2, maxOutputTokens: 1024 }
- };
- // Use image URL reference
- body.contents[0].parts = [
- { text: prompt },
- { text: `Image URL for reference: ${product.img}` }
- ];
-
- // Actually fetch the image and send inline
- const imgBuf = await fetchImage(product.img);
- if (imgBuf) {
- body.contents[0].parts = [
- { text: prompt },
- { inlineData: { mimeType: 'image/jpeg', data: imgBuf.toString('base64') } }
- ];
- }
-
- const resp = await fetchJSON(GEMINI_URL, body);
- if (resp.error) throw new Error(resp.error.message);
-
- const text = resp.candidates?.[0]?.content?.parts?.[0]?.text || '';
- // Strip markdown fences if present
- const clean = text.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
- return JSON.parse(clean);
-}
-
-function fetchImage(url) {
- return new Promise((resolve) => {
- const mod = url.startsWith('https') ? https : http;
- mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- return fetchImage(res.headers.location).then(resolve);
- }
- if (res.statusCode !== 200) { resolve(null); return; }
- let chunks = [];
- res.on('data', c => chunks.push(c));
- res.on('end', () => resolve(Buffer.concat(chunks)));
- }).on('error', () => resolve(null));
- });
-}
-
-async function main() {
- const db = new Client({ connectionString: DB });
- await db.connect();
-
- let success = 0, failed = 0;
-
- for (const p of products) {
- try {
- console.log(`[${p.vendor}] Analyzing ${p.sku} - ${p.name}...`);
- const ai = await analyzeWithGemini(p);
-
- const colorsStr = (ai.colors || []).join(', ');
- const tagsStr = (ai.tags || []).join(', ');
- const stylesStr = (ai.styles || []).join(', ');
- const patternsStr = (ai.patterns || []).join(', ');
-
- // Update vendor_catalog
- await db.query(`
- UPDATE vendor_catalog SET
- ai_colors = $1,
- ai_background_color = $2,
- ai_styles = $3,
- ai_patterns = $4,
- ai_tags = $5,
- ai_description = $6
- WHERE id = $7
- `, [colorsStr, ai.backgroundColor, stylesStr, patternsStr, tagsStr, ai.description, p.id]);
-
- // Also update arte_catalog if Arte vendor
- if (p.vendor === 'arte') {
- await db.query(`
- UPDATE arte_catalog SET
- ai_colors = $1,
- ai_background_color = $2,
- ai_styles = $3,
- ai_patterns = $4,
- ai_tags = $5,
- ai_description = $6
- WHERE arte_sku = $7
- `, [colorsStr, ai.backgroundColor, stylesStr, patternsStr, tagsStr, ai.description, p.sku]);
- }
-
- console.log(` ✅ ${p.sku}: colors=[${colorsStr}] bg=${ai.backgroundColor}`);
- success++;
-
- // Rate limit: ~300ms between calls
- await new Promise(r => setTimeout(r, 300));
- } catch (err) {
- console.error(` ❌ ${p.sku}: ${err.message}`);
- failed++;
- }
- }
-
- console.log(`\nDone: ${success} succeeded, ${failed} failed out of ${products.length}`);
- await db.end();
-}
-
-main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/ncw-cleanup/run-full-20260506-095239.log b/scripts/ncw-cleanup/run-full-20260506-095239.log
deleted file mode 100644
index 2230ce0e..00000000
--- a/scripts/ncw-cleanup/run-full-20260506-095239.log
+++ /dev/null
@@ -1,77 +0,0 @@
-# NCW Cleanup — connected to Designer Wallcoverings and Fabrics (designer-laboratory-sandbox.myshopify.com)
-# Mode: execute
-# Started: 2026-05-06T16:52:39.407Z
-
-## Plan
-- Archive (dupes) : 227
-- Add Color: tag (live-checked): 318 EUR products to inspect
-- Normalize mfr_sku to NCW####-##: 304 EUR products
-
-## Archiving 227 duplicates
- archived 10/227
- archived 20/227
- archived 30/227
- archived 40/227
- archived 50/227
- archived 60/227
- archived 70/227
- archived 80/227
- archived 90/227
- archived 100/227
- archived 110/227
- archived 120/227
- archived 130/227
- archived 140/227
- archived 150/227
- archived 160/227
- archived 170/227
- archived 180/227
- archived 190/227
- archived 200/227
- archived 210/227
- archived 220/227
-
-## Checking live tags + adding Color: where missing on 318 EUR products
- progress: tagged=22 already=3 skipped=0
- progress: tagged=47 already=3 skipped=0
- progress: tagged=64 already=11 skipped=0
- ? no color in: EUR-80189 → "Pamir 03 - Neutral Wallcovering | Nina Campbell"
- ? no color in: EUR-80191 → "Pamir 05 - Goldenrod Wallcovering | Nina Campbell"
- ? no color in: EUR-80203 → "Mahayana 06 - Onyx Wallcovering | Nina Campbell"
- ? no color in: EUR-80210 → "Khitan 07 | Nina Campbell Europe"
- ? no color in: EUR-80216 → "Keightley's Folio 01 - Pastel Wallcovering | Nina Campbell"
- ? no color in: EUR-80255 → "Fontibre 02 - Warm Neutral Wallcovering | Nina Campbell"
- ? no color in: EUR-80270 → "Pavilion Garden 04 - Onyx Wallcovering | Nina Campbell"
- ? no color in: EUR-80281 → "Palmetto 05 - Warm Neutral Wallcovering | Nina Campbell"
- progress: tagged=123 already=19 skipped=8
- ? no color in: EUR-80293 → "Meredith 03 - Taupe Wallcovering | Nina Campbell"
- progress: tagged=142 already=24 skipped=9
- progress: tagged=167 already=24 skipped=9
- ? no color in: EUR-80351 → "Baville 01 - Neutral Wallcovering | Nina Campbell"
- ? no color in: EUR-80360 → "Bonnelles Diamond 04 - Bisque Wallcovering | Nina Campbell"
- progress: tagged=190 already=24 skipped=11
- ? no color in: EUR-80384 → "Fortoiseau 05 - Onyx Wallcovering | Nina Campbell"
- progress: tagged=214 already=24 skipped=12
- ? no color in: EUR-80414 → "Kingsley Fans 04 - Neutral Wallcovering | Nina Campbell"
- progress: tagged=238 already=24 skipped=13
- ? no color in: EUR-80444 → "Meridor Stripe 04 - Neutral Wallcovering | Nina Campbell"
- progress: tagged=262 already=24 skipped=14
- ? no color in: EUR-80455 → "Plumier Stripe 05 - Neutral Wallcovering | Nina Campbell"
- tag pass complete: tagged=279 already_had=24 skipped=15 errors=0
-
-## Normalizing mfr_sku → NCW####-## for 304 EUR products
- mfr progress: fixed=13 already=37
- mfr progress: fixed=33 already=67
- mfr progress: fixed=43 already=82
- mfr progress: fixed=68 already=82
- mfr progress: fixed=81 already=94
- mfr progress: fixed=96 already=104
- mfr progress: fixed=112 already=113
- mfr progress: fixed=152 already=148
- mfr pass complete: fixed=156 already_correct=148 errors=0
-
-## Done
- archived OK : 227/227 (errors: 0)
- tagged OK : 279/318 (skipped: 15, errors: 0)
- mfr fixed : 156/304 (already_correct: 148, errors: 0)
- audit log : /Users/stevestudio2/Projects/Designer-Wallcoverings/scripts/ncw-cleanup/audit-execute-2026-05-06T17-08-22.json
diff --git a/scripts/ncw-cleanup/run-tick2-20260506-100853.log b/scripts/ncw-cleanup/run-tick2-20260506-100853.log
deleted file mode 100644
index 1cf58126..00000000
--- a/scripts/ncw-cleanup/run-tick2-20260506-100853.log
+++ /dev/null
@@ -1,48 +0,0 @@
-# NCW Cleanup — connected to Designer Wallcoverings and Fabrics (designer-laboratory-sandbox.myshopify.com)
-# Mode: execute
-# Started: 2026-05-06T17:08:53.362Z
-
-## Plan
-- Archive (dupes) : 227
-- Add Color: tag (live-checked): 318 EUR products to inspect
-- Normalize mfr_sku to NCW####-##: 148 EUR products
-
-## Archiving 227 duplicates
- archived 10/227
- archived 20/227
- archived 30/227
- archived 40/227
- archived 50/227
- archived 60/227
- archived 70/227
- archived 80/227
- archived 90/227
- archived 100/227
- archived 110/227
- archived 120/227
- archived 130/227
- archived 140/227
- archived 150/227
- archived 160/227
- archived 170/227
- archived 180/227
- archived 190/227
- archived 200/227
- archived 210/227
- archived 220/227
-
-## Checking live tags + adding Color: where missing on 318 EUR products
- ? no color in: EUR-80191 → "Pamir 05 - Goldenrod Wallcovering | Nina Campbell"
- ? no color in: EUR-80210 → "Khitan 07 | Nina Campbell Europe"
- ? no color in: EUR-80216 → "Keightley's Folio 01 - Pastel Wallcovering | Nina Campbell"
- progress: tagged=3 already=119 skipped=3
- tag pass complete: tagged=12 already_had=303 skipped=3 errors=0
-
-## Normalizing mfr_sku → NCW####-## for 148 EUR products
- mfr pass complete: fixed=0 already_correct=148 errors=0
-
-## Done
- archived OK : 227/227 (errors: 0)
- tagged OK : 12/318 (skipped: 3, errors: 0)
- mfr fixed : 0/148 (already_correct: 148, errors: 0)
- audit log : /Users/stevestudio2/Projects/Designer-Wallcoverings/scripts/ncw-cleanup/audit-execute-2026-05-06T17-14-02.json
← f19be762 security: strip hardcoded dw_admin DSN password -> env-first
·
back to Designer Wallcoverings
·
security: strip hardcoded PGPASSWORD/pm2/shell-string secret d287f9d6 →