commit e5b8504bc613497dab5936fe4c1231500c4b6723 Author: type-two Date: Fri Jul 24 13:47:09 2026 +1000 Import pliceclogs as-is — the original Discogs seller extension Snapshot of the working tree exactly as it stood, no edits. This is the predecessor PRICEGOD was rewritten from ("kept intact, untouched" per pricegod/README.md), and it is still the only place the DYMO scale is actually implemented — rfid-daemon/index.js:1068-1265: HID discovery, parseScaleReport, one-shot read, and a streaming /weight + /scale/start|stop session whose JSON shape PRICEGOD's daemon.js already speaks. Preserved verbatim on purpose (hence -og), including the known bug: DYMO_PIDS at index.js:1082 is [0x8003, 0x8004], so it cannot see the bench M25 (0x8009). Fix that in whatever daemon inherits the scale, not here. node_modules stays ignored (22M of the 24M tree). No credentials in the import: the two PEM markers in utils.js/sheets.js only strip headers off a key read from settings, and mrpadmin / johnking are an SSH and a Postgres username, both key/trust auth. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43db421 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +node_modules/ +*.bak +prompt-prep*.txt +pliceclogs-labelcode.txt +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..1db9203 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +This Chrome extension streamlines pricing, inventory management, and label printing for Discogs sellers. It integrates directly with Discogs release pages to automate price tag generation, Google Sheets logging, and price data collection. + +Features + +Core Functionality + • Price Tag Generation: Works on discogs.com/release/ pages. When adding a release to your collection, set media condition, sleeve condition, price, and notes. Clicking the popup button generates a price tag for printing. + • Discogs API Integration: Fetches price suggestions via an authenticated API call. + • Google Sheets Sync: Logs pricing details in a Google Sheet using a service account. + +Label & Sheet Buttons + • Label: Opens a printable price tag. + • Sheet: Logs the release details in Google Sheets. + • Both: Performs both actions simultaneously. + +Label Printing & Customization + • Thermal Printer Support: Designed for generic thermal label printers. + • Dynamic Label Layout: Labels are mapped to Discogs info (artist, genre, etc.), and the container size, div positions, and SVG logo can be adjusted. + • Planned UI Panel: A future update will add a UI settings panel for live preview and customization of label layout, size, and elements. + +Additional Modes + • Average Price Mode: Once a release is logged in the sheet, the extension can fetch the “average price” visible on https://www.discogs.com/sell/post/ (manual HTML load required). + • Global Seller Data Mode: Retrieves pricing data from https://www.discogs.com/sell/release/ for future auto-pricing algorithms (manual HTML load required due to Discogs’ robots.txt restrictions). + +Google Sheets Integration + • Requires a Google Sheets service account for write access. + • A future update will add a UX settings panel to input credentials and auto-generate credentials.json. + +Installation + 1. Install the extension via Chrome Developer Mode. + 2. Navigate to discogs.com/release/ pages to use the popup. + 3. Configure Google Sheets access (for write functionality). + +Future Plans + • UI settings panel for live label customization and preview. + • Automated pricing algorithm using global seller data. + + + + Pricing & Workflow Summary + + 1. Find Release on Discogs → Locate the exact release page. + 2. Add to Collection → Input media condition, sleeve condition, price, notes, and assign to a folder. + 3. Generate Price Label & Log Data: + • Press Popup → Loads price suggestions and saved details. + • Press BOTH → Opens a new window with a printable price label and writes data to Google Sheets. + • Press Label → Prints a new label without writing to Sheets. + • Press Sheet → Writes to Sheets without printing a new label. + 4. Sell a Copy Workflow: + • Click “Sell a Copy” (https://www.discogs.com/sell/post/). + • Popup changes to show average price from Discogs. + • Press Update → Saves the average price to Google Sheets (matching by Release ID). + 5. Global Seller Data: + • Click “Copies for Sale” (https://www.discogs.com/sell/release/). + • Popup updates to show all global sellers’ prices and conditions. + • Press Zap to Sheet → Logs this data to Google Sheets for future auto-pricing algorithms. \ No newline at end of file diff --git a/background.js b/background.js new file mode 100644 index 0000000..52c00de --- /dev/null +++ b/background.js @@ -0,0 +1,1098 @@ +// Shared capture helpers (parseSalesHistoryHtml, parseListingsHtml, +// buildResearchJson, combineReleaseData) — same module the popup loads. +importScripts('monster_capture.js'); + +// ── MV3 keepalive ───────────────────────────────────────────────────── +// chrome.alarms fires even when the service worker is hibernated, waking it. +// Combined with the setInterval pollers below, this keeps PliceCogs's COGS +// proxy poll loop alive indefinitely without needing an open discogs tab. +chrome.alarms.create('plicecogs-keepalive', { periodInMinutes: 0.5 }); +chrome.alarms.onAlarm.addListener(() => { /* wake-only, no work */ }); + +// YouTube Cookie Management + +// BLAGGINATE bridge connection variables +let blagginateTabId = null; +let isBlagginateConnected = false; +let lastBlagginateConnectionAttempt = 0; +let blagginateConnectionAttempts = 0; + +// Listen for YouTube tab updates +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete' && tab.url?.includes('youtube.com')) { + chrome.storage.local.get(['autoUpdate'], function(result) { + if (result.autoUpdate === true) { + updateCookies(); + } + }); + } +}); + +function updateCookies() { + console.log('Starting updateCookies function...'); + return new Promise((resolve, reject) => { + try { + chrome.cookies.getAll({ domain: ".youtube.com" }, function(cookies) { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + + console.log(`Found ${cookies.length} cookies`); + + // Format cookies in Netscape format + let cookieText = "# Netscape HTTP Cookie File\n"; + cookieText += "# https://curl.haxx.se/rfc/cookie_spec.html\n"; + cookieText += "# This file was generated by YouTube Cookie Extractor\n\n"; + + cookies.forEach(cookie => { + cookieText += [ + ".youtube.com", + "TRUE", + cookie.path, + cookie.secure ? "TRUE" : "FALSE", + Math.floor(cookie.expirationDate || (Date.now() / 1000) + 31536000), + cookie.name, + cookie.value + ].join('\t') + '\n'; + }); + + // Store cookie text in local storage + chrome.storage.local.set({ + lastCookie: cookieText, + lastUpdate: Date.now() + }, () => { + console.log('Cookie saved to storage'); + + // Push to ytanalyse cookie server if it's running (silently no-ops if not) + fetch('http://localhost:7792/cookies', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: cookieText, + }).catch(() => {}); // ytanalyse not running — that's fine + + // Send cookie update to BLAGGINATE page if connected + if (blagginateTabId && isBlagginateConnected) { + chrome.tabs.sendMessage(blagginateTabId, { + type: 'cookieUpdate', + cookies: cookieText, + timestamp: Date.now() + }).catch(error => { + console.log('Failed to send cookie to BLAGGINATE page:', error); + // Reset connection status if tab is no longer available + isBlagginateConnected = false; + blagginateTabId = null; + }); + console.log('Cookie sent to BLAGGINATE page'); + } + + // Resolve with cookie text + resolve({ cookieText: cookieText, success: true }); + }); + }); + } catch (error) { + reject(error); + } + }); +} + +// Build Cookie-Editor / EditThisCookie style JSON array — the format +// JDownloader 2 (and most cookie-importers) expect when pasting cookies. +function getYouTubeCookiesJson() { + return new Promise((resolve, reject) => { + chrome.cookies.getAll({ domain: ".youtube.com" }, (cookies) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + const out = cookies.map(c => ({ + domain: c.domain, + expirationDate: c.expirationDate, // omitted for session cookies + hostOnly: !!c.hostOnly, + httpOnly: !!c.httpOnly, + name: c.name, + path: c.path, + // chrome.cookies sameSite: "no_restriction" | "lax" | "strict" | "unspecified" + // Cookie-Editor uses the same vocabulary, so pass through. + sameSite: c.sameSite || "unspecified", + secure: !!c.secure, + session: !!c.session, + storeId: c.storeId || null, + value: c.value + })); + resolve(JSON.stringify(out, null, 2)); + }); + }); +} + +function keepAlive() { + setInterval(() => { + chrome.runtime.getPlatformInfo(() => { + if (chrome.runtime.lastError) { + console.log('Keeping service worker alive'); + } + }); + }, 20000); +} + +// BLAGGINATE bridge connection system +function connectToBlagginate() { + // Check if BLAGGINATE bridge is enabled + chrome.storage.local.get(['blagginateBridge'], (result) => { + if (result.blagginateBridge === false) { + return; // Bridge is disabled, don't attempt connection + } + + const now = Date.now(); + + // Throttle connection attempts and logging + if (now - lastBlagginateConnectionAttempt < 30000) { // 30 seconds + return; + } + + lastBlagginateConnectionAttempt = now; + blagginateConnectionAttempts++; + + // Look for BLAGGINATE page tab + chrome.tabs.query({}, (tabs) => { + const blagginateTab = tabs.find(tab => + tab.url && tab.url.includes('wowplatter-admin-blagginate') + ); + + if (blagginateTab) { + blagginateTabId = blagginateTab.id; + // Test connection by sending a ping + chrome.tabs.sendMessage(blagginateTabId, { + type: 'ping', + timestamp: Date.now() + }).then(() => { + isBlagginateConnected = true; + blagginateConnectionAttempts = 0; // Reset counter on successful connection + console.log('Connected to BLAGGINATE page'); + // Send initial cookie update + updateCookies(); + }).catch(error => { + if (blagginateConnectionAttempts <= 3) { // Only log first 3 attempts + console.log('BLAGGINATE page not ready for communication:', error); + } + isBlagginateConnected = false; + blagginateTabId = null; + }); + } else { + if (blagginateConnectionAttempts <= 3) { // Only log first 3 attempts + console.log('BLAGGINATE page not found'); + } + isBlagginateConnected = false; + blagginateTabId = null; + } + }); + }); +} + +// Listen for messages from content scripts (including BLAGGINATE page) +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'toggle_blagginate_bridge') { + // Handle BLAGGINATE bridge toggle from popup + const enabled = message.enabled; + chrome.storage.local.set({ blagginateBridge: enabled }, () => { + if (!enabled) { + // If disabled, disconnect from BLAGGINATE + isBlagginateConnected = false; + blagginateTabId = null; + console.log('BLAGGINATE bridge disabled'); + } else { + console.log('BLAGGINATE bridge enabled'); + // Try to reconnect if enabled + connectToBlagginate(); + } + }); + sendResponse({ success: true }); + return; + } + + if (message.type === 'blagginate_ready') { + // BLAGGINATE page is ready to receive messages + blagginateTabId = sender.tab.id; + isBlagginateConnected = true; + console.log('BLAGGINATE page connected'); + + // Send current cookies + updateCookies().then(() => { + sendResponse({ success: true }); + }); + return true; // Keep message channel open for async response + } + + if (message.type === 'get_cookies') { + // BLAGGINATE page requesting current cookies + chrome.storage.local.get(['lastCookie'], (result) => { + sendResponse({ + cookies: result.lastCookie || null, + timestamp: Date.now() + }); + }); + return true; // Keep message channel open for async response + } +}); + +// Check for BLAGGINATE connection periodically +setInterval(() => { + if (!isBlagginateConnected) { + connectToBlagginate(); + } +}, 10000); // Check every 10 seconds + +// Listen for tab removal to clean up connection +chrome.tabs.onRemoved.addListener((tabId) => { + if (tabId === blagginateTabId) { + console.log('BLAGGINATE tab closed'); + isBlagginateConnected = false; + blagginateTabId = null; + } +}); + +// PriceClogs functionality +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + // YouTube Cookie requests + if (request.action === "getCookies") { + console.log('Received getCookies request from popup'); + updateCookies() + .then(result => { + console.log('UpdateCookies completed:', result); + sendResponse(result); + }) + .catch(error => { + console.error('UpdateCookies failed:', error); + sendResponse({ + error: error.message || 'Failed to update cookies', + success: false + }); + }); + return true; + } + + // YouTube cookies in Cookie-Editor JSON format (for JDownloader 2 paste-into-password-field flow) + if (request.action === "getCookiesJson") { + getYouTubeCookiesJson() + .then(json => sendResponse({ success: true, cookieJson: json })) + .catch(err => sendResponse({ success: false, error: err.message || String(err) })); + return true; + } + + if (request.action === "postGeminiResult") { + const result = request.result || {}; + console.log('[gemini] postGeminiResult received', { + release_id: result.release_id, + job_id: result.job_id, + prompt_chars: (result.prompt || '').length, + output_chars: (result.output_text || '').length, + }); + chrome.storage.local.get(['monsterSettings'], async (r) => { + const cfg = r.monsterSettings || {}; + const remoteBase = cfg.serverUrl ? cfg.serverUrl : 'http://100.91.239.7:5002'; + const bases = []; + if (cfg.preferLocalhost === true) bases.push('http://localhost:5002'); + bases.push(remoteBase); + const urls = bases + .map(b => String(b || '').trim()) + .filter(Boolean) + .map(b => b.replace(/\/$/, '') + '/plice/gemini'); + try { + let lastErr = null; + for (const url of urls) { + try { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(result), + signal: AbortSignal.timeout(8000), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + console.log('[gemini] POST failed', { url, status: resp.status, body: text.slice(0, 500) }); + lastErr = `HTTP ${resp.status} ${text}`.trim(); + continue; + } + const data = await resp.json().catch(() => ({})); + console.log('[gemini] POST ok', { url, data }); + chrome.storage.local.set({ + geminiPostLast: { + ok: true, + url, + data, + at: new Date().toISOString(), + } + }); + sendResponse({ ok: true, data, url }); + return; + } catch (e) { + const msg = e.message || String(e); + console.log('[gemini] POST exception', { url, error: msg }); + lastErr = msg; + continue; + } + } + + chrome.storage.local.set({ + geminiPostLast: { + ok: false, + urls, + error: lastErr || 'All POST attempts failed', + at: new Date().toISOString(), + } + }); + sendResponse({ ok: false, error: lastErr || 'All POST attempts failed' }); + } catch (e) { + sendResponse({ ok: false, error: e.message || 'Fetch failed' }); + } + }); + return true; + } + + if (request.action === "closeSenderTab") { + const tabId = sender && sender.tab ? sender.tab.id : null; + if (!tabId) { + sendResponse({ ok: false, error: 'No sender tab' }); + return true; + } + chrome.tabs.remove(tabId, () => { + if (chrome.runtime.lastError) { + sendResponse({ ok: false, error: chrome.runtime.lastError.message }); + } else { + sendResponse({ ok: true }); + } + }); + return true; + } + + // PriceClogs file management + if (request.action === "checkFileExists") { + chrome.downloads.search({ + filename: request.filename, + exists: true + }, async (downloadItems) => { + try { + if (downloadItems && downloadItems.length > 0) { + const response = await fetch(`file://${downloadItems[0].filename}`); + if (response.ok) { + const data = await response.json(); + sendResponse({ exists: true, data: data }); + } else { + sendResponse({ exists: false }); + } + } else { + sendResponse({ exists: false }); + } + } catch (error) { + console.error('Error checking file:', error); + sendResponse({ exists: false }); + } + }); + return true; + } + + if (request.action === "downloadFile") { + chrome.downloads.download({ + url: request.url, + filename: request.filename, + conflictAction: 'overwrite', + saveAs: false + }, (downloadId) => { + if (chrome.runtime.lastError) { + sendResponse({ success: false, error: chrome.runtime.lastError }); + } else { + sendResponse({ success: true, downloadId }); + } + }); + return true; + } + + if (request.action === "getBeatportToken") { + fetchBeatportToken(sendResponse); + return true; + } + + if (request.action === "refreshBeatportToken") { + fetchBeatportToken((result) => { + if (result && result.success && result.tokenJson) { + chrome.storage.local.set({ + beatportAuth: { data: result.tokenJson, timestamp: Date.now() } + }, () => sendResponse(result)); + } else { + sendResponse(result); + } + }); + return true; + } +}); + +function fetchBeatportToken(sendResponse) { + console.log("Fetching Beatport token..."); + + // Cloudflare blocks service-worker fetches with a bot challenge (cf-mitigated: challenge → 403). + // So we run the fetch inside an open beatport.com tab where the page already has CF clearance. + // Fall back to a direct SW fetch only if no tab exists (will likely 403, but worth a try). + chrome.tabs.query({ url: "*://*.beatport.com/*" }, (tabs) => { + const handleResult = (data, errMsg) => { + if (errMsg) { + console.error("Beatport fetch error:", errMsg); + sendResponse({ + success: false, + error: "fetch_failed", + message: "Failed to connect to Beatport. Are you logged in? (Open a beatport.com tab and try again.)" + }); + return; + } + if (data && data.token && data.token.accessToken && data.token.accessToken !== 'session') { + sendResponse({ success: true, tokenJson: JSON.stringify(data) }); + } else { + sendResponse({ + success: false, + error: "token_empty", + message: "Beatport returned an empty session. Please log out and back in on beatport.com." + }); + } + }; + + if (tabs && tabs.length > 0) { + const tabId = tabs[0].id; + chrome.scripting.executeScript({ + target: { tabId }, + world: "MAIN", + func: async () => { + try { + const r = await fetch('/api/auth/session', { credentials: 'include' }); + if (!r.ok) return { __err: `HTTP ${r.status}` }; + return await r.json(); + } catch (e) { + return { __err: String(e && e.message || e) }; + } + } + }, (results) => { + if (chrome.runtime.lastError) { + handleResult(null, chrome.runtime.lastError.message); + return; + } + const data = results && results[0] && results[0].result; + if (data && data.__err) { + handleResult(null, data.__err); + } else { + handleResult(data, null); + } + }); + return; + } + + // No beatport tab open — try direct fetch as a last resort. + fetch('https://www.beatport.com/api/auth/session', { credentials: 'include' }) + .then(async (resp) => { + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + handleResult(await resp.json(), null); + }) + .catch((err) => handleResult(null, err && err.message || String(err))); + }); +} + +// Add external message handler for YouTube cookies +chrome.runtime.onMessageExternal.addListener( + function(request, sender, sendResponse) { + if (request.action === "refreshCookie") { + updateCookies() + .then(result => { + console.log('Cookies refreshed via external request'); + sendResponse({ success: true, timestamp: Date.now() }); + }) + .catch(error => { + console.error('Failed to refresh cookies:', error); + sendResponse({ success: false, error: error.message }); + }); + return true; + } + } +); + +function setActionPopup(tabId, url) { + chrome.action.setPopup({ tabId: tabId, popup: 'popup.html' }); +} + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete' && tab.url) { + // console.log('Tab updated:', tab.url); + + setActionPopup(tabId, tab.url); + + // Check if we're on a Discogs release page + if (tab.url.includes('discogs.com/release/')) { + // console.log('Discogs release page detected'); + + // Check if settings are configured before injecting + chrome.storage.local.get(['discogsToken', 'googleSettings', 'wpSettings'], (result) => { + // console.log('Checking settings for tab:', tabId); + // console.log('Settings found:', { + // hasDiscogsToken: !!result.discogsToken, + // hasGoogleSettings: !!result.googleSettings, + // hasWpSettings: !!result.wpSettings + // }); + + const hasDiscogsToken = result.discogsToken && result.discogsToken.trim() !== ''; + + if (hasDiscogsToken) { + // console.log('Settings configured, injecting content script'); + chrome.scripting.executeScript({ + target: { tabId: tabId }, + files: ['content.js'] + }).catch(err => { + // console.error('Failed to inject content script:', err); + }); + } else { + // console.log('Settings not configured, skipping content script injection'); + } + }); + } + } +}); + +chrome.tabs.onActivated.addListener(activeInfo => { + chrome.tabs.get(activeInfo.tabId, (tab) => { + if(tab) { + setActionPopup(tab.id, tab.url); + } + }); +}); + +// Set for currently active tab when the extension is loaded +chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs[0]) { + setActionPopup(tabs[0].id, tabs[0].url); + } +}); + +// Initialize YouTube Cookie functionality +chrome.runtime.onInstalled.addListener(() => { + // console.log('Extension installed'); + keepAlive(); +}); + +// Handle messages from content scripts and popup +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + console.log('Background: Received message:', message.type); + + if (message.type === 'blagginate_ready') { + // BLAGGINATE page is ready for communication + if (sender.tab) { + blagginateTabId = sender.tab.id; + isBlagginateConnected = true; + console.log('BLAGGINATE page ready, tab ID:', blagginateTabId); + + // Send initial cookies + updateCookies(); + sendResponse({ success: true }); + } + return true; + } + + if (message.type === 'get_cookies') { + // Request for fresh cookies from BLAGGINATE page + chrome.storage.local.get(['youtubeCookies'], (result) => { + if (result.youtubeCookies) { + sendResponse({ + success: true, + cookies: result.youtubeCookies + }); + } else { + // Get fresh cookies + updateCookies().then(() => { + chrome.storage.local.get(['youtubeCookies'], (freshResult) => { + sendResponse({ + success: true, + cookies: freshResult.youtubeCookies || null + }); + }); + }); + } + }); + return true; // Keep message channel open for async response + } + + if (message.type === 'force_cookie_update') { + // Force update cookies (can be called from popup) + updateCookies(); + sendResponse({ success: true }); + return true; + } +}); + +// Handle tab removal - clean up if BLAGGINATE tab is closed +chrome.tabs.onRemoved.addListener((tabId) => { + if (tabId === blagginateTabId) { + console.log('BLAGGINATE tab closed'); + isBlagginateConnected = false; + blagginateTabId = null; + } +}); + +// External message listener for WordPress BLAGGINATE communication +chrome.runtime.onMessageExternal.addListener((request, sender, sendResponse) => { + console.log('External message received:', request, 'from:', sender); + + // Handle WordPress BLAGGINATE cookie requests + if (request.action === "getCookies") { + console.log('WordPress requesting cookies via external message'); + + updateCookies() + .then(result => { + console.log('Cookies updated for WordPress:', result); + sendResponse({ + success: true, + cookieText: result.cookieText, + timestamp: Date.now() + }); + }) + .catch(error => { + console.error('Failed to get cookies for WordPress:', error); + sendResponse({ + success: false, + error: error.message || 'Failed to get cookies' + }); + }); + + return true; // Keep message channel open for async response + } + + // Handle Beatport token extraction requests + if (request.action === "getBeatportToken") { + fetchBeatportToken(sendResponse); + return true; + } + + // Handle WordPress ping/status requests + if (request.action === "ping" || request.action === "status") { + console.log('WordPress ping received'); + sendResponse({ + success: true, + status: 'connected', + timestamp: Date.now() + }); + return true; + } + + // Get Discogs cookies for the batch researcher node script + if (request.action === "getDiscogsCookies") { + chrome.cookies.getAll({ url: "https://www.discogs.com" }, (cookies) => { + const cookieHeader = cookies.map(c => `${c.name}=${c.value}`).join('; '); + sendResponse({ success: true, cookieHeader }); + }); + return true; + } + + // Get Discogs page HTML via a real Chrome tab (bypasses Cloudflare JS challenge) + // Used by the batch researcher node script via the local proxy server + if (request.action === "fetchDiscogsPage") { + fetchPageViaTab(request.url) + .then(html => sendResponse({ success: true, html })) + .catch(err => sendResponse({ success: false, error: err.message })); + return true; // async + } + + // Handle unknown requests + console.log('Unknown external message action:', request.action); + sendResponse({ + success: false, + error: 'Unknown action: ' + request.action + }); + return true; +}); + +// ─── Batch researcher page proxy ────────────────────────────────────────────── +// Polls the node batch script's local proxy server (localhost:7789). +// When a Discogs URL is queued, opens it in a real background Chrome tab so +// Cloudflare's JS challenge can execute, then returns the HTML to the node script. + +let batchProxyTabId = null; + +function ensureBatchProxyTab() { + return new Promise((resolve) => { + if (batchProxyTabId !== null) { + chrome.tabs.get(batchProxyTabId, (tab) => { + if (chrome.runtime.lastError || !tab) { + batchProxyTabId = null; + chrome.tabs.create({ url: 'about:blank', active: false }, (t) => { + batchProxyTabId = t.id; + resolve(t.id); + }); + } else { + resolve(batchProxyTabId); + } + }); + } else { + chrome.tabs.create({ url: 'about:blank', active: false }, (t) => { + batchProxyTabId = t.id; + resolve(t.id); + }); + } + }); +} + +function fetchPageViaTab(url) { + return ensureBatchProxyTab().then(tabId => new Promise((resolve, reject) => { + let done = false; + + const hardTimeout = setTimeout(() => { + if (!done) { done = true; reject(new Error('Proxy tab timeout after 60s')); } + }, 60000); + + function extractAfterLoad() { + if (done) return; + // Run in the tab: scroll to reviews, click Load More until gone, return outerHTML + chrome.scripting.executeScript({ + target: { tabId }, + func: async () => { + // Quick CF check first + if (document.title === 'Just a moment...' || document.body?.innerHTML?.includes('__cf_chl_')) { + return '__CF_BLOCKED__'; + } + + // Scroll to the reviews section to trigger lazy loading + const reviewSection = document.querySelector('#release-reviews'); + if (reviewSection) { + reviewSection.scrollIntoView({ behavior: 'instant' }); + } else { + window.scrollTo(0, document.body.scrollHeight); + } + + // Wait for the loading spinner inside #release-reviews to disappear + // Discogs uses svg[aria-label="loading"] while fetching reviews + for (let i = 0; i < 40; i++) { + await new Promise(r => setTimeout(r, 500)); + const spinner = document.querySelector('#release-reviews svg[aria-label="loading"]'); + if (!spinner) break; // reviews loaded (or section has no reviews) + } + + // Click "Load More Reviews" button until gone + for (let clicks = 0; clicks < 30; clicks++) { + const btn = document.querySelector('#release-reviews button'); + if (!btn) break; + btn.click(); + await new Promise(r => setTimeout(r, 1500)); + // Wait for any new spinner to clear after clicking + for (let i = 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 500)); + if (!document.querySelector('#release-reviews svg[aria-label="loading"]')) break; + } + } + + return document.documentElement.outerHTML; + } + }, (results) => { + if (done) return; + const html = results?.[0]?.result || ''; + if (html === '__CF_BLOCKED__') { + if (!done) { setTimeout(() => extractAfterLoad(), 3000); } + return; + } + const isCF = html.includes('Just a moment') || html.includes('__cf_chl_') || html.length < 500; + if (isCF) { + setTimeout(() => extractAfterLoad(), 3000); + return; + } + done = true; + clearTimeout(hardTimeout); + resolve(html); + }); + } + + function onUpdated(updTabId, changeInfo) { + if (updTabId !== tabId || changeInfo.status !== 'complete') return; + chrome.tabs.onUpdated.removeListener(onUpdated); + setTimeout(extractAfterLoad, 1500); + } + + chrome.tabs.onUpdated.addListener(onUpdated); + chrome.tabs.update(tabId, { url }); + })); +} + +/** + * Run the Discogs DeferredReleaseData GraphQL query. + * Uses a GET request to the correct endpoint with URL-encoded params. + * Executed inside a real Discogs tab so cookies are automatically included. + */ +async function fetchDiscogsGraphQLViaTab(discogsId) { + // Prefer an already-open Discogs tab so we don't need to wait for a page load + const tabs = await new Promise(resolve => + chrome.tabs.query({ url: '*://*.discogs.com/*' }, resolve)); + + let tabId = tabs && tabs.length > 0 ? tabs[0].id : null; + let createdTab = false; + + if (!tabId) { + const t = await new Promise(resolve => + chrome.tabs.create({ url: 'https://www.discogs.com/', active: false }, resolve)); + tabId = t.id; + createdTab = true; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Tab load timeout')), 20000); + function onUpd(id, info) { + if (id !== tabId || info.status !== 'complete') return; + chrome.tabs.onUpdated.removeListener(onUpd); + clearTimeout(timeout); + resolve(); + } + chrome.tabs.onUpdated.addListener(onUpd); + }); + } + + try { + const results = await new Promise((resolve, reject) => { + chrome.scripting.executeScript({ + target: { tabId }, + func: async (id) => { + try { + const params = new URLSearchParams({ + operationName: 'DeferredReleaseData', + variables: JSON.stringify({ discogsId: id }), + extensions: JSON.stringify({ + persistedQuery: { + version: 1, + sha256Hash: 'd11719156ba242b7a0c478272025ba913c71a934c8597ef9cb51e30962a17fb4', + }, + }), + }); + const url = `https://www.discogs.com/service/catalog/api/graphql?${params}`; + const resp = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json, */*', + 'apollographql-client-name': 'release-page-client', + }, + }); + return await resp.text(); + } catch (e) { + return JSON.stringify({ __fetchError: e.message }); + } + }, + args: [discogsId], + }, (r) => { + if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); + else resolve(r); + }); + }); + + const text = results && results[0] && results[0].result; + if (!text) throw new Error('executeScript returned no result'); + return text; + } finally { + if (createdTab) chrome.tabs.remove(tabId); + } +} + +async function batchProxyPoll() { + try { + // ── HTML page fetch (opens a real tab) ──────────────────────────────── + const resp = await fetch('http://localhost:7789/pending-fetch', + { signal: AbortSignal.timeout(3000) }); + if (resp.status === 200) { + const { requestId, url } = await resp.json(); + + let html = null, error = null; + try { html = await fetchPageViaTab(url); } + catch (e) { error = e.message; } + + await fetch('http://localhost:7789/fetch-result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId, html, error }), + }); + } + } catch { + // Node server not running — normal when batch script isn't active + } + + try { + // ── GraphQL fetch (executed inside a Discogs tab — same-origin, cookies automatic) ─ + const gresp = await fetch('http://localhost:7789/pending-graphql', + { signal: AbortSignal.timeout(3000) }); + if (gresp.status === 200) { + const { requestId, discogsId } = await gresp.json(); + + let json = null, error = null; + try { + json = await fetchDiscogsGraphQLViaTab(Number(discogsId)); + } catch (e) { error = e.message; } + + await fetch('http://localhost:7789/graphql-result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId, json, error }), + }); + } + } catch { + // Normal when server not running + } +} + +// Poll every 2 seconds. Silently no-ops when the node server isn't running. +setInterval(batchProxyPoll, 2000); + +// ─── Beatport token refresh poll (for beatport_fill.py) ─────────────────────── +// Polls the ytanalyse cookie-server to see if Python has requested a fresh token. +// When it has, fetches from beatport.com/api/auth/session and delivers it. +async function beatportTokenPoll() { + try { + const resp = await fetch('http://localhost:7792/beatport-pending', + { signal: AbortSignal.timeout(2000) }); + if (resp.status !== 200) return; + const { pending } = await resp.json(); + if (!pending) return; + + console.log('Beatport token requested by Python — attempting live fetch...'); + + // Always try a live fetch first so we get the freshest token. + // Falls back to whatever is in storage only if the live fetch returns 'session' + // (meaning the Beatport browser session has expired). + fetchBeatportToken((result) => { + if (result && result.success && result.tokenJson) { + console.log('Beatport: live fetch succeeded, delivering to cookie-server'); + fetch('http://localhost:7792/beatport-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: result.tokenJson, + }).catch(() => {}); + return; + } + + // Live fetch returned 'session' — Beatport browser session likely expired. + // Fall back to storage, but only if the stored token is less than 55 minutes old. + console.warn('Beatport: live fetch returned session/empty, checking storage...'); + chrome.storage.local.get(['beatportAuth'], (stored) => { + const auth = stored.beatportAuth; + const ageMs = Date.now() - (auth?.timestamp || 0); + const tokenJson = auth?.data; + if (tokenJson && ageMs < 55 * 60 * 1000) { + try { + const parsed = JSON.parse(tokenJson); + const tokenObj = parsed.token || parsed; + const accessToken = tokenObj.accessToken || tokenObj.access_token; + if (accessToken && accessToken !== 'session') { + console.log('Beatport: delivering stored token (age: ' + Math.round(ageMs/60000) + 'min)'); + fetch('http://localhost:7792/beatport-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: tokenJson, + }).catch(() => {}); + return; + } + } catch { /* invalid JSON */ } + } + // Nothing usable — POST an error so Python stops waiting immediately + console.error('Beatport: no valid token available — browser session expired?'); + fetch('http://localhost:7792/beatport-token-error', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'session_expired' }), + }).catch(() => {}); + }); + }); + } catch { + // cookie-server not running — normal + } +} + +// Poll every 3 seconds. Silently no-ops when cookie-server isn't running. +setInterval(beatportTokenPoll, 3000); + +// ─── MONSTERWIKI passive auto-capture ───────────────────────────────────────── +// When "Auto-capture while browsing" is on, every Discogs release page you open +// is harvested silently (DOM via content.js + 1 Discogs API call for the +// canonical release object + sell-page scrape) and POSTed to the plice server. +// No popup needed. + +const mcRecent = new Map(); // release_id → last-capture epoch ms +const MC_DEDUP_MS = 10 * 60 * 1000; // ponytail: re-capture same release after 10min, not every scroll/SPA re-render + +async function mcGetReleaseApi(id, token) { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const r = await fetch(`https://api.discogs.com/releases/${id}`, { + headers: { 'Authorization': `Discogs token=${token}`, 'User-Agent': 'PriceClogs/1.0' } + }); + if (r.status === 429) { await new Promise(s => setTimeout(s, 1500 * (attempt + 1))); continue; } + const data = await r.json(); + if (data && data.artists) return data; + } catch (_) {} + await new Promise(s => setTimeout(s, 1000)); + } + return null; +} + +async function mcGetPriceSuggestions(id, token) { + try { + const r = await fetch(`https://api.discogs.com/marketplace/price_suggestions/${id}`, { + headers: { 'Authorization': `Discogs token=${token}`, 'User-Agent': 'PriceClogs/1.0' } + }); + return r.ok ? await r.json() : null; + } catch (_) { return null; } +} + +function mcResolveBase(cfg) { + if (cfg.preferLocalhost) return 'http://localhost:5002'; + return (cfg.serverUrl || 'http://100.91.239.7:5002').replace(/\/$/, ''); +} + +async function monsterAutoCapture(tabId, releaseId) { + const cfg = await new Promise(res => chrome.storage.local.get(['monsterSettings'], r => res(r.monsterSettings || {}))); + if (!cfg.autoCapture) return; + + const last = mcRecent.get(releaseId); + if (last && (Date.now() - last) < MC_DEDUP_MS) return; + mcRecent.set(releaseId, Date.now()); // mark early so concurrent loads don't double-fire + + const token = await new Promise(res => chrome.storage.local.get('discogsToken', d => res((d.discogsToken || '').trim() || null))); + if (!token) { console.warn('[autocapture] no Discogs token — skipping', releaseId); mcRecent.delete(releaseId); return; } + + try { + // DOM scrape (content.js already auto-injected on discogs.com) — gives + // market stats, reviews, recommendations, apple id, cover image. + const dom = await new Promise(res => { + chrome.tabs.sendMessage(tabId, { action: 'getInitialValues' }, resp => { + res(chrome.runtime.lastError ? {} : (resp || {})); + }); + }); + + const [api, priceSuggestions, histHtml, listHtml] = await Promise.all([ + mcGetReleaseApi(releaseId, token), + mcGetPriceSuggestions(releaseId, token), + fetch(`https://www.discogs.com/sell/history/${releaseId}`, { credentials: 'include' }).then(r => r.ok ? r.text() : null).catch(() => null), + fetch(`https://www.discogs.com/sell/list?release_id=${releaseId}`, { credentials: 'include' }).then(r => r.ok ? r.text() : null).catch(() => null), + ]); + + if (!api) { console.warn('[autocapture] API release fetch failed', releaseId); mcRecent.delete(releaseId); return; } + + const rd = combineReleaseData(api, dom, priceSuggestions); + const payload = { + ...buildResearchJson(rd), + sales_history: histHtml ? parseSalesHistoryHtml(histHtml) : null, + current_listings: listHtml ? parseListingsHtml(listHtml) : null, + }; + + const base = mcResolveBase(cfg); + const resp = await fetch(`${base}/plice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(12000), + }); + if (resp.ok) console.log('[autocapture] ✓ release', releaseId, '→', base); + else { console.warn('[autocapture] POST failed', resp.status, releaseId); mcRecent.delete(releaseId); } + } catch (e) { + console.warn('[autocapture] error', releaseId, e.message); + mcRecent.delete(releaseId); // allow retry on next navigation + } +} + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status !== 'complete' || !tab.url) return; + // Release pages only: /release/ or //release/, not master/sell. + const m = tab.url.match(/discogs\.com\/(?:[^/]+\/)?release\/(\d+)/); + if (m) monsterAutoCapture(tabId, m[1]); +}); diff --git a/barcode.js b/barcode.js new file mode 100644 index 0000000..0aa3856 --- /dev/null +++ b/barcode.js @@ -0,0 +1,21 @@ +document.addEventListener('DOMContentLoaded', () => { + const barcodeField = document.querySelector('.print-barcode'); + if (barcodeField && window.releaseData) { + generateQRCode(window.releaseData.id, barcodeField); + } +}); + +function generateQRCode(releaseId, element) { + if (!element || !releaseId) return; + + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=100x100&data=${releaseId}`; + element.innerHTML = `${releaseId}`; +} + +// Export function for use in other modules +window.generateQRCode = generateQRCode; + +function generateBarcode(data) { + // Logic to generate barcode using Code128 font + return `*${data}*`; // Example format +} \ No newline at end of file diff --git a/blagginate-bridge.js b/blagginate-bridge.js new file mode 100644 index 0000000..fcaee02 --- /dev/null +++ b/blagginate-bridge.js @@ -0,0 +1,152 @@ +// BLAGGINATE Bridge Content Script +// This script runs on the BLAGGINATE admin page to establish communication with the extension + +console.log('BLAGGINATE Bridge: Content script loaded'); + +// Global variable to store cookies +window.youtubeCookies = null; +window.lastCookieUpdate = null; + +// Function to handle cookie updates from the extension +function handleCookieUpdate(cookies) { + window.youtubeCookies = cookies; + window.lastCookieUpdate = Date.now(); + console.log('BLAGGINATE Bridge: YouTube cookies updated'); + + // Trigger custom event for the BLAGGINATE page to listen to + const event = new CustomEvent('youtubeCookiesUpdated', { + detail: { + cookies: cookies, + timestamp: window.lastCookieUpdate + } + }); + document.dispatchEvent(event); +} + +// Function to get current cookies (can be called by BLAGGINATE page) +window.getYouTubeCookies = function() { + return { + cookies: window.youtubeCookies, + timestamp: window.lastCookieUpdate + }; +}; + +// Beatport token bridge — mirrors the YouTube cookies pattern +window.getBeatportToken = function() { + return new Promise(function(resolve) { + chrome.storage.local.get(['beatportAuth'], function(result) { + resolve(result.beatportAuth || null); + }); + }); +}; + +window.requestFreshBeatportToken = function() { + return new Promise(function(resolve, reject) { + chrome.runtime.sendMessage({ action: 'refreshBeatportToken' }, function(response) { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + resolve(response); + }); + }); +}; + +// Function to request fresh cookies from extension +window.requestFreshCookies = function() { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ + type: 'get_cookies' + }, (response) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + + if (response && response.cookies) { + handleCookieUpdate(response.cookies); + resolve(response); + } else { + reject(new Error('No cookies available')); + } + }); + }); +}; + +// Listen for messages from the background script +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + console.log('BLAGGINATE Bridge: Received message:', message.type); + + if (message.type === 'cookieUpdate') { + handleCookieUpdate(message.cookies); + sendResponse({ success: true }); + return true; + } + + if (message.type === 'ping') { + console.log('BLAGGINATE Bridge: Ping received'); + sendResponse({ success: true, timestamp: Date.now() }); + return true; + } +}); + +// Notify background script that BLAGGINATE page is ready +function notifyReady() { + chrome.runtime.sendMessage({ + type: 'blagginate_ready' + }, (response) => { + if (chrome.runtime.lastError) { + console.log('BLAGGINATE Bridge: Failed to connect to background script:', chrome.runtime.lastError); + // Retry after a delay + setTimeout(notifyReady, 2000); + } else { + console.log('BLAGGINATE Bridge: Successfully connected to extension background'); + } + }); +} + +// Wait for page to be fully loaded before notifying +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + setTimeout(notifyReady, 1000); // Small delay to ensure page is ready + }); +} else { + setTimeout(notifyReady, 1000); +} + +// Add visual indicator that the bridge is active +function addBridgeIndicator() { + const indicator = document.createElement('div'); + indicator.id = 'youtube-cookie-bridge-indicator'; + indicator.style.cssText = ` + position: fixed; + top: 10px; + right: 10px; + background: #4CAF50; + color: white; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + font-family: Arial, sans-serif; + z-index: 10000; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + `; + indicator.textContent = 'YouTube Cookie Bridge: Connected'; + document.body.appendChild(indicator); + + // Update indicator when cookies are received + document.addEventListener('youtubeCookiesUpdated', () => { + indicator.style.background = '#2196F3'; + indicator.textContent = 'YouTube Cookie Bridge: Cookies Updated'; + setTimeout(() => { + indicator.style.background = '#4CAF50'; + indicator.textContent = 'YouTube Cookie Bridge: Connected'; + }, 2000); + }); +} + +// Add indicator when page is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', addBridgeIndicator); +} else { + addBridgeIndicator(); +} + +console.log('BLAGGINATE Bridge: Initialization complete'); \ No newline at end of file diff --git a/constants.js b/constants.js new file mode 100644 index 0000000..bf929fb --- /dev/null +++ b/constants.js @@ -0,0 +1,37 @@ +const SELECTORS = { + collectionBox: 'div.box_PFmyl.collection_DQxgF', + boxSelectors: { + mediaCondition: '.field_nm6Jt:nth-of-type(1) .dvalue_fevTQ', + sleeveCondition: '.field_nm6Jt:nth-of-type(2) .dvalue_fevTQ', + price: '.field_nm6Jt:nth-of-type(4) .markup_Cngxi', + comment: '.field_nm6Jt:nth-of-type(3) .markup_Cngxi', + collectionFolder: '.field_nm6Jt:nth-of-type(6) .dvalue_fevTQ' + }, + appleMusic: '#audio-iframe', + marketStats: { + have: '#release-stats .items_PQSxS li:nth-child(1) a', + want: '#release-stats .items_PQSxS li:nth-child(2) a', + lastSold: '#release-stats .items_PQSxS li:nth-child(5) a time', + lowPrice: '#release-stats .items_PQSxS li:nth-child(6) span', + medianPrice: '#release-stats .items_PQSxS li:nth-child(7) span', + highPrice: '#release-stats .items_PQSxS li:nth-child(8) span', + artistElement: 'h1.title_Brnd1 a', + labelElement: ".info_LD8Ql a[href*='/label/']", + imageElement: '.thumbnail_cgf1w img' + }, + reviews: { + container: '#reviews', + loadMoreButton: '#release-reviews > div > button', + seeRepliesButton: 'button', + reviewItem: '.review_luKwE:not(.replies_r6FWL .review_luKwE)', + replyItem: '.review_luKwE', + username: '.username_N7O6q', + profileUrl: '.username_N7O6q', + avatar: '.pic_Z6wyD img', + date: 'time.created_EKCAa', + rating: '.rating_pea6E', + text: '.markup_Cngxi', + helpfulButton: '.button_PgYDF.link_ijVx7.button_YXKHZ', + repliesContainer: '.replies_r6FWL' + } +}; \ No newline at end of file diff --git a/content.js b/content.js new file mode 100644 index 0000000..0b8fd13 --- /dev/null +++ b/content.js @@ -0,0 +1,776 @@ +// Only run on Discogs pages to avoid interfering with other sites +if (window.location.hostname.includes('discogs.com') && !window.hasRun) { + // console.log('PriceClogs: Initializing on Discogs page'); + window.hasRun = true; + + const SELECTORS = { + releaseTitle: 'h1.title_Brnd1', + collectionBox: 'div.box_PFmyl.collection_DQxgF', + statsBox: '#release-stats', + appleMusic: '#audio-iframe', + marketStats: { + artistElement: 'h1.title_Brnd1 a', + labelElement: ".info_LD8Ql a[href*='/label/']", + imageElement: '.thumbnail_cgf1w img' + } + }; + + async function waitForElement(selector, timeout = 7000) { + return new Promise((resolve, reject) => { + const element = document.querySelector(selector); + if (element) { + return resolve(element); + } + const observer = new MutationObserver((mutations, obs) => { + const element = document.querySelector(selector); + if (element) { + obs.disconnect(); + resolve(element); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + setTimeout(() => { + observer.disconnect(); + reject(new Error(`Timeout waiting for element: ${selector}`)); + }, timeout); + }); + } + + function extractReleaseId() { + try { + const href = window.location.href; + const match = href.match(/\/release\/(\d+)/); + if (match) return match[1]; + const sellMatch = href.match(/\/sell\/release\/(\d+)/); + if (sellMatch) return sellMatch[1]; + const link = document.querySelector('a[href*="/release/"]'); + if (link) { + const lm = link.href.match(/\/release\/(\d+)/); + if (lm) return lm[1]; + } + const pathNum = (window.location.pathname.match(/\d{5,}/) || [])[0]; + if (pathNum) return pathNum; + } catch (e) {} + return null; + } + + async function expandAllReviews() { + console.log('[DEBUG] Starting to expand all reviews...'); + + // First, scroll to bottom to trigger review loading + console.log('[DEBUG] Scrolling to bottom to load reviews...'); + const originalScroll = window.scrollY; + window.scrollTo(0, document.body.scrollHeight); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Click "Load More" buttons until no more exist + while (true) { + const loadMoreButton = document.querySelector('#release-reviews > div > button'); + if (!loadMoreButton) { + console.log('[DEBUG] No more "Load More" buttons found'); + break; + } + try { + loadMoreButton.click(); + console.log('[DEBUG] Clicked "Load More Reviews"'); + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (error) { + console.error('[DEBUG] Error clicking load more:', error); + break; + } + } + + // Then expand all "See Replies" buttons + let keepChecking = true; + while (keepChecking) { + const seeRepliesButtons = document.querySelectorAll('button.button_PgYDF.link_ijVx7.button_YXKHZ'); + keepChecking = false; + + for (const button of seeRepliesButtons) { + if (button.textContent.includes('See')) { + try { + button.click(); + console.log('[DEBUG] Clicked "See Replies"'); + keepChecking = true; + await new Promise(resolve => setTimeout(resolve, 500)); + } catch (error) { + console.error('[DEBUG] Error clicking see replies:', error); + } + } + } + if (keepChecking) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + // Restore original scroll position + window.scrollTo(0, originalScroll); + console.log('[DEBUG] Finished expanding all reviews'); + } + + function extractHelpfulCount(reviewEl) { + const helpfulButton = Array.from(reviewEl.querySelectorAll('.button_PgYDF.link_ijVx7.button_YXKHZ')) + .find(button => button.textContent.includes('Helpful')); + const helpfulText = helpfulButton?.textContent?.trim() || ''; + return helpfulText === 'Helpful' ? 0 : parseInt(helpfulText) || 0; + } + + function extractReviews() { + console.log('[DEBUG] Starting review extraction...'); + const reviews = []; + + // Get all top-level review elements (those not in replies containers) + const reviewElements = document.querySelectorAll('.review_luKwE:not(.replies_r6FWL .review_luKwE)'); + console.log(`[DEBUG] Found ${reviewElements.length} top-level review elements`); + + reviewElements.forEach(reviewEl => { + // Extract the main review data + const review = { + id: reviewEl.id?.replace('#', '') || '', + username: reviewEl.querySelector('.username_N7O6q')?.innerText?.trim() || '', + profileUrl: reviewEl.querySelector('.username_N7O6q')?.href || '', + avatarUrl: reviewEl.querySelector('.pic_Z6wyD img')?.src || '', + date: reviewEl.querySelector('time.created_EKCAa')?.getAttribute('datetime') || '', + rating: extractRating(reviewEl), + text: reviewEl.querySelector('.markup_Cngxi')?.innerText?.trim() || '', + helpfulCount: extractHelpfulCount(reviewEl), + replies: [] + }; + + // Find replies container that follows this review + const repliesContainer = reviewEl.nextElementSibling; + if (repliesContainer?.classList.contains('replies_r6FWL')) { + // Get all reply elements within this container + const replyElements = repliesContainer.querySelectorAll('.review_luKwE'); + replyElements.forEach(replyEl => { + const reply = { + id: replyEl.id?.replace('#', '') || '', + username: replyEl.querySelector('.username_N7O6q')?.innerText?.trim() || '', + profileUrl: replyEl.querySelector('.username_N7O6q')?.href || '', + avatarUrl: replyEl.querySelector('.pic_Z6wyD img')?.src || '', + date: replyEl.querySelector('time.created_EKCAa')?.getAttribute('datetime') || '', + rating: extractRating(replyEl), + text: replyEl.querySelector('.markup_Cngxi')?.innerText?.trim() || '', + helpfulCount: extractHelpfulCount(replyEl), + parentId: review.id + }; + review.replies.push(reply); + }); + } + + reviews.push(review); + }); + + // Sort top-level reviews by date (newest first) + reviews.sort((a, b) => new Date(b.date) - new Date(a.date)); + + // Debug logging + console.log(`[DEBUG] Extracted reviews: ${reviews.length}`); + reviews.forEach(review => { + console.log(`[DEBUG] Review ${review.id} has ${review.replies.length} replies`); + }); + + return reviews; + } + + function extractRating(reviewEl) { + const ratingLabel = reviewEl.querySelector('.rating_pea6E')?.getAttribute('aria-label') || ''; + const match = ratingLabel.match(/rated this release (\d+) star/); + return match ? parseInt(match[1]) : 0; + } + + function extractRecommendations() { + console.log('[DEBUG] Starting recommendation extraction...'); + const recommendations = []; + const items = document.querySelectorAll("#release-recommendations > div > div > ul > li"); + + console.log(`[DEBUG] Found ${items.length} recommendation elements`); + + items.forEach(item => { + const recommendation = { + title: item.querySelector(".title_XMp5_")?.innerText?.trim() || '', + artist: item.querySelector("._artistName_1xmn1_9")?.innerText?.trim() || '', + releaseLink: item.querySelector("._link_bcmpa_1._hideUnderLine_bcmpa_25")?.href || '', + coverImage: item.querySelector(".thumbnailContainer_pYnh1 img")?.src || '', + releaseDetails: item.querySelector(".dateAndCountry_Yllip")?.innerText?.trim() || '', + format: { + primary: item.querySelector(".primaryFormat_Fy_7v")?.innerText?.trim() || '', + details: item.querySelector("._concatenatedText_14c0h_1")?.innerText?.trim() || '' + } + }; + + if (recommendation.title || recommendation.artist) { + recommendations.push(recommendation); + } + }); + + console.log(`[DEBUG] Extracted ${recommendations.length} recommendations`); + return recommendations; + } + + async function extractMarketData() { + console.log('[DEBUG] extractMarketData called.'); + const data = { artistId: 'N/A', labelId: 'N/A', imageUrl: 'N/A', lastSold: 'N/A', lowPrice: 'N/A', medianPrice: 'N/A', highPrice: 'N/A', have: 'N/A', want: 'N/A', sellerPriceRange: '' }; + try { + const statsContainer = await waitForElement(SELECTORS.statsBox); + console.log('[DEBUG] #release-stats container found.'); + const statItems = statsContainer.querySelectorAll('.items_PQSxS li'); + statItems.forEach(item => { + const nameEl = item.querySelector('.name_qjn4_'); + if (!nameEl) return; + const name = nameEl.textContent.replace(/|:/g, '').trim(); + const valueEl = item.querySelector('a, span:not(.name_qjn4_)'); + if (!valueEl) return; + const valueText = valueEl.textContent.trim(); + if (name.includes('Have')) data.have = valueText; + else if (name.includes('Want')) data.want = valueText; + else if (name.includes('Last Sold')) data.lastSold = valueEl.querySelector('time')?.getAttribute('datetime') || 'N/A'; + else if (name.includes('Low')) data.lowPrice = valueText.replace(/^[A-Z$£€¥₹]+/, ''); + else if (name.includes('Median')) data.medianPrice = valueText.replace(/^[A-Z$£€¥₹]+/, ''); + else if (name.includes('High')) data.highPrice = valueText.replace(/^[A-Z$£€¥₹]+/, ''); + }); + const artistElement = document.querySelector(SELECTORS.marketStats.artistElement); + if (artistElement) data.artistId = artistElement.getAttribute('href').match(/\/artist\/(\d+)/)?.[1] || 'N/A'; + const labelElement = document.querySelector(SELECTORS.marketStats.labelElement); + if (labelElement) data.labelId = labelElement.getAttribute('href').match(/\/label\/(\d+)/)?.[1] || 'N/A'; + const imageElement = document.querySelector(SELECTORS.marketStats.imageElement); + if (imageElement) data.imageUrl = imageElement.src; + // Extract seller price range from shopping box + const shoppingPriceEl = document.querySelector('.shopping-box-price .shopping-box-copy'); + if (shoppingPriceEl) { + const rangeMatch = shoppingPriceEl.textContent.trim().match(/From\s+[A-Z]*\$?([\d,.]+)\s+to\s+[A-Z]*\$?([\d,.]+)/i); + if (rangeMatch) { + data.sellerPriceRange = `$${rangeMatch[1]}-$${rangeMatch[2]}`; + } + } + } catch (error) { + console.error('[DEBUG] CRITICAL ERROR in extractMarketData:', error); + } + console.log('[DEBUG] Extracted market data object:', data); + return data; + } + + function getCollectionData(collectionBoxElement) { + console.log('[DEBUG] getCollectionData called.'); + const collectionData = { mediaCondition: '', sleeveCondition: '', price: 'N/A', comment: 'N/A', collectionFolder: 'N/A' }; + if (!collectionBoxElement) { + console.log('[DEBUG] Collection box element was NOT found.'); + return collectionData; + } + console.log('[DEBUG] Collection box element found. Extracting data...'); + const fields = collectionBoxElement.querySelectorAll('.field_nm6Jt'); + fields.forEach(field => { + const labelEl = field.querySelector('label.label_KxNWU'); + if (!labelEl) return; + const label = labelEl.textContent.trim(); + let value = field.querySelector('.dvalue_fevTQ, .markup_Cngxi')?.textContent.trim() || ''; + if (label.includes('Media Condition')) collectionData.mediaCondition = value; + else if (label.includes('Sleeve Condition')) collectionData.sleeveCondition = value; + else if (label.includes('comment')) collectionData.comment = value || 'N/A'; + else if (label.includes('price')) collectionData.price = value || 'N/A'; + else if (label.includes('Folder')) collectionData.collectionFolder = value || 'N/A'; + else if (label === 'SKU') collectionData.collectionSku = value || ''; + }); + console.log('[DEBUG] Extracted collection data:', collectionData); + return collectionData; + } + + async function getInitialValues() { + console.log('[DEBUG] getInitialValues called.'); + + // Check if we should run extraction based on settings + const shouldExtract = await checkIfShouldExtract(); + if (!shouldExtract) { + console.log('[DEBUG] Settings not configured, returning minimal data'); + return { + mediaCondition: 'Very Good Plus (VG+)', + sleeveCondition: 'Very Good Plus (VG+)', + price: '', + comment: '', + collectionFolder: '', + reviews: [], + recommendations: [] + }; + } + + try { + await waitForElement(SELECTORS.releaseTitle); + console.log('[DEBUG] Sanity check passed: Release title found.'); + } catch (error) { + console.error('[DEBUG] SANITY CHECK FAILED: Could not find release title. Aborting.', error); + return {}; + } + let collectionBoxElement; + try { + // Wait for at least one collection box to appear + await waitForElement(SELECTORS.collectionBox, 3000); + + // Find all collection boxes and select the last one + const collectionBoxes = document.querySelectorAll(SELECTORS.collectionBox); + if (collectionBoxes.length > 0) { + collectionBoxElement = collectionBoxes[collectionBoxes.length - 1]; + console.log(`[DEBUG] Found ${collectionBoxes.length} collection box(es), using the last one.`); + } else { + collectionBoxElement = null; + } + } catch (error) { + console.log('[DEBUG] "In Collection" box not found on this page.'); + collectionBoxElement = null; + } + const values = getCollectionData(collectionBoxElement); + // Expose multi-box info so the popup can render a copy selector + const allBoxes = document.querySelectorAll(SELECTORS.collectionBox); + values.collectionBoxCount = allBoxes.length; + values.collectionBoxSkus = Array.from(allBoxes).map(b => getCollectionData(b).collectionSku || ''); + values.collectionBoxIndex = allBoxes.length - 1; + const marketData = await extractMarketData(); + Object.assign(values, marketData); + + // Extract reviews with nested structure + await expandAllReviews(); + values.reviews = extractReviews(); + + // Extract recommendations + values.recommendations = extractRecommendations(); + + const iframe = document.querySelector(SELECTORS.appleMusic); + if (iframe && iframe.src) { + // URL shape: https://embed.music.apple.com/{country}/album/{slug?}/{id} + const match = iframe.src.match(/\/([a-z]{2})\/album\/(?:[^/?]+\/)?(\d+)/); + values.appleId = match ? match[2] : null; + values.appleCountry = match ? match[1] : null; + } + console.log('[DEBUG] FINAL EXTRACTED DATA with nested reviews and recommendations:', values); + return values; + } + + // Function to check if extraction should run based on settings + async function checkIfShouldExtract() { + return new Promise((resolve) => { + chrome.storage.local.get(['discogsToken', 'googleSettings', 'wpSettings'], (result) => { + const hasDiscogsToken = result.discogsToken && result.discogsToken.trim() !== ''; + const hasGoogleSettings = result.googleSettings && + result.googleSettings.spreadsheetId && + result.googleSettings.clientEmail && + result.googleSettings.privateKey; + const hasWpSettings = result.wpSettings && + result.wpSettings.url && + result.wpSettings.username && + result.wpSettings.password; + + // At least one service should be configured + const hasValidSettings = hasDiscogsToken && (hasGoogleSettings || hasWpSettings); + console.log('[DEBUG] Content script settings check:', { hasDiscogsToken, hasGoogleSettings, hasWpSettings, hasValidSettings }); + resolve(hasValidSettings); + }); + }); + } + + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + try { + if (request.action === 'getInitialValues') { + getInitialValues().then(sendResponse).catch(error => sendResponse({ error: error.message })); + return true; + } + if (request.action === 'getMarketData') { + extractMarketData().then(sendResponse).catch(error => sendResponse({ error: error.message })); + return true; + } + if (request.action === 'selectCollectionBox') { + const boxes = document.querySelectorAll(SELECTORS.collectionBox); + const box = boxes[request.index]; + if (box) sendResponse({ ok: true, ...getCollectionData(box) }); + else sendResponse({ ok: false, error: 'Box not found' }); + return true; + } + if (request.action === 'getReviews') { + expandAllReviews().then(() => { + const reviews = extractReviews(); + sendResponse({ reviews }); + }).catch(error => { + // console.error('Error extracting reviews:', error); + sendResponse({ error: error.message }); + }); + return true; + } + } catch (error) { + // console.error('Error processing message:', error); + sendResponse({ error: error.message }); + } + return true; + }); +} else { + // console.log('PriceClogs: Skipping initialization - not on Discogs page'); +} + +// Global, synchronous helpers used by the bottom message listener +function extractReleaseId() { + try { + const href = window.location.href; + const match = href.match(/\/release\/(\d+)/); + if (match) return match[1]; + const sellMatch = href.match(/\/sell\/release\/(\d+)/); + if (sellMatch) return sellMatch[1]; + const link = document.querySelector('a[href*="/release/"]'); + if (link) { + const lm = link.href.match(/\/release\/(\d+)/); + if (lm) return lm[1]; + } + const pathNum = (window.location.pathname.match(/\d{5,}/) || [])[0]; + if (pathNum) return pathNum; + } catch (e) {} + return null; +} + +function extractInitialValues() { + const values = { + mediaCondition: '', + sleeveCondition: '', + price: '', + comment: '', + collectionFolder: '', + artistId: 'N/A', + labelId: 'N/A', + imageUrl: 'N/A', + lastSold: 'N/A', + lowPrice: 'N/A', + medianPrice: 'N/A', + highPrice: 'N/A', + have: 'N/A', + want: 'N/A', + sellerPriceRange: '', + appleId: null, + reviews: [], + recommendations: [] + }; + + try { + // Collection box values (last one if multiple) + const collectionBoxes = document.querySelectorAll('div.box_PFmyl.collection_DQxgF'); + const collectionBoxElement = collectionBoxes.length ? collectionBoxes[collectionBoxes.length - 1] : null; + if (collectionBoxElement) { + const fields = collectionBoxElement.querySelectorAll('.field_nm6Jt'); + fields.forEach(field => { + const labelEl = field.querySelector('label.label_KxNWU'); + if (!labelEl) return; + const label = labelEl.textContent.trim(); + let value = field.querySelector('.dvalue_fevTQ, .markup_Cngxi')?.textContent.trim() || ''; + if (label.includes('Media Condition')) values.mediaCondition = value; + else if (label.includes('Sleeve Condition')) values.sleeveCondition = value; + else if (label.toLowerCase().includes('comment')) values.comment = value; + else if (label.toLowerCase().includes('price')) values.price = value; + else if (label.includes('Folder')) values.collectionFolder = value; + else if (label === 'SKU') values.collectionSku = value || ''; + }); + } + + // Market stats and related IDs + const statsContainer = document.querySelector('#release-stats'); + if (statsContainer) { + const statItems = statsContainer.querySelectorAll('.items_PQSxS li'); + statItems.forEach(item => { + const nameEl = item.querySelector('.name_qjn4_'); + if (!nameEl) return; + const name = nameEl.textContent.replace(/|:/g, '').trim(); + const valueEl = item.querySelector('a, span:not(.name_qjn4_)'); + if (!valueEl) return; + const valueText = valueEl.textContent.trim(); + if (name.includes('Have')) values.have = valueText; + else if (name.includes('Want')) values.want = valueText; + else if (name.includes('Last Sold')) values.lastSold = valueEl.querySelector('time')?.getAttribute('datetime') || 'N/A'; + else if (name.includes('Low')) values.lowPrice = valueText.replace(/^[A-Z$£€¥₹]+/, ''); + else if (name.includes('Median')) values.medianPrice = valueText.replace(/^[A-Z$£€¥₹]+/, ''); + else if (name.includes('High')) values.highPrice = valueText.replace(/^[A-Z$£€¥₹]+/, ''); + }); + + const artistEl = document.querySelector('h1.title_Brnd1 a'); + if (artistEl) values.artistId = artistEl.getAttribute('href').match(/\/artist\/(\d+)/)?.[1] || 'N/A'; + const labelEl = document.querySelector(".info_LD8Ql a[href*='/label/']"); + if (labelEl) values.labelId = labelEl.getAttribute('href').match(/\/label\/(\d+)/)?.[1] || 'N/A'; + const imageEl = document.querySelector('.thumbnail_cgf1w img'); + if (imageEl) values.imageUrl = imageEl.src; + } + + // Seller price range from shopping box + const shoppingPriceEl = document.querySelector('.shopping-box-price .shopping-box-copy'); + if (shoppingPriceEl) { + const rangeMatch = shoppingPriceEl.textContent.trim().match(/From\s+[A-Z]*\$?([\d,.]+)\s+to\s+[A-Z]*\$?([\d,.]+)/i); + if (rangeMatch) { + values.sellerPriceRange = `$${rangeMatch[1]}-$${rangeMatch[2]}`; + } + } + + // Apple Music album id + const iframe = document.querySelector('#audio-iframe'); + if (iframe && iframe.src) { + const match = iframe.src.match(/album\/(\d+)/); + values.appleId = match ? match[1] : null; + } + } catch (e) { + // fail silently; return whatever we collected + } + + return values; +} + +// Function to update the Discogs price field on the page +async function updateDiscogsPrice(priceValue) { + console.log('[DEBUG] updateDiscogsPrice called with:', priceValue); + + // Find ALL price labels on the page and take the last one (= newest collection box) + const allPriceLabels = Array.from(document.querySelectorAll('.label_KxNWU')) + .filter(el => el.textContent.toLowerCase().trim() === 'price'); + + console.log('[DEBUG] Found', allPriceLabels.length, 'price label(s) on page'); + + if (!allPriceLabels.length) { + console.error('[DEBUG] Price label not found'); + return { success: false, error: 'Price label not found' }; + } + + const priceLabel = allPriceLabels[allPriceLabels.length - 1]; // last = newest collection box + console.log('[DEBUG] Found price label (last one)'); + + // Get the parent field container + const priceField = priceLabel.closest('.field_nm6Jt'); + if (!priceField) { + console.error('[DEBUG] Price field container not found'); + return { success: false, error: 'Price field container not found' }; + } + console.log('[DEBUG] Found price field container, innerHTML:', priceField.innerHTML.substring(0, 200)); + + // Check if there's already an input/textarea visible (edit mode already active) + let input = priceField.querySelector('textarea.textarea_pO4kR, textarea, input[type="text"]'); + console.log('[DEBUG] Initial input search result:', input); + + if (!input) { + // Click the button to enter edit mode - try multiple approaches + const editButton = priceField.querySelector('button.wrapper_F6O4e, button[aria-label="Edit Notes"]'); + if (!editButton) { + console.error('[DEBUG] Edit button not found'); + return { success: false, error: 'Edit button not found' }; + } + + console.log('[DEBUG] Found edit button, clicking...'); + + // Try multiple click methods + editButton.focus(); + editButton.click(); + + // Also try dispatching mouse events + editButton.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window })); + editButton.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window })); + editButton.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + + console.log('[DEBUG] Clicked edit button, waiting for textarea...'); + + // Wait for the textarea to appear with polling + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + input = priceField.querySelector('textarea.textarea_pO4kR, textarea, input[type="text"]'); + if (input) { + console.log('[DEBUG] Found input after', (i + 1) * 100, 'ms'); + break; + } + } + } + + if (!input) { + console.error('[DEBUG] Input/textarea not found after clicking edit. Current field HTML:', priceField.innerHTML); + return { success: false, error: 'Input field not found after clicking edit' }; + } + + console.log('[DEBUG] Found input field:', input.tagName, input.className); + + // Focus the input first + input.focus(); + await new Promise(resolve => setTimeout(resolve, 50)); + + // Clear and set the value using native setter for React compatibility + const isTextarea = input.tagName.toLowerCase() === 'textarea'; + const nativeSetter = Object.getOwnPropertyDescriptor( + isTextarea ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype, + 'value' + )?.set; + + // Clear first + if (nativeSetter) { + nativeSetter.call(input, ''); + } + input.dispatchEvent(new Event('input', { bubbles: true })); + + await new Promise(resolve => setTimeout(resolve, 50)); + + // Now set the value + if (nativeSetter) { + nativeSetter.call(input, priceValue); + } else { + input.value = priceValue; + } + + // Dispatch events to ensure React picks up the change + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + + // Simulate typing by dispatching input event with data + input.dispatchEvent(new InputEvent('input', { + bubbles: true, + cancelable: true, + inputType: 'insertText', + data: priceValue + })); + + console.log('[DEBUG] Set price value to:', priceValue, 'Current input value:', input.value); + + // Wait a moment for React to process the input + await new Promise(resolve => setTimeout(resolve, 200)); + + // Find and click the save button + const saveButton = priceField.querySelector('button.save_q8lO5, button.green_DL05T, button:has(.save), button[class*="save"]'); + if (saveButton) { + console.log('[DEBUG] Found save button, clicking...'); + saveButton.click(); + saveButton.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + console.log('[DEBUG] Clicked save button'); + } else { + console.log('[DEBUG] Save button not found, trying blur instead'); + input.blur(); + document.body.click(); + } + + return { success: true }; +} + +// Function to update the Discogs SKU field on the page +async function updateDiscogsSkuField(skuValue, boxIndex) { + console.log('[DEBUG] updateDiscogsSkuField called with:', skuValue, 'boxIndex:', boxIndex); + + // Target the specific collection box (by index) or fall back to the last one + const allBoxes = document.querySelectorAll('div.box_PFmyl.collection_DQxgF'); + const targetBox = (boxIndex != null && allBoxes[boxIndex]) ? allBoxes[boxIndex] : allBoxes[allBoxes.length - 1]; + + const allSkuLabels = targetBox + ? Array.from(targetBox.querySelectorAll('.label_KxNWU')).filter(el => el.textContent.trim() === 'SKU') + : Array.from(document.querySelectorAll('.label_KxNWU')).filter(el => el.textContent.trim() === 'SKU'); + + console.log('[DEBUG] Found', allSkuLabels.length, 'SKU label(s) in target box'); + + if (!allSkuLabels.length) { + return { success: false, error: 'SKU label not found' }; + } + + const skuLabel = allSkuLabels[0]; // first (only) SKU field in this box + + const skuField = skuLabel.closest('.field_nm6Jt'); + if (!skuField) { + return { success: false, error: 'SKU field container not found' }; + } + + let input = skuField.querySelector('textarea.textarea_pO4kR, textarea, input[type="text"]'); + + if (!input) { + const editButton = skuField.querySelector('button.wrapper_F6O4e, button[aria-label="Edit Notes"]'); + if (!editButton) { + return { success: false, error: 'Edit button not found' }; + } + editButton.focus(); + editButton.click(); + editButton.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window })); + editButton.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window })); + editButton.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + input = skuField.querySelector('textarea.textarea_pO4kR, textarea, input[type="text"]'); + if (input) break; + } + } + + if (!input) { + return { success: false, error: 'Input field not found after clicking edit' }; + } + + input.focus(); + await new Promise(resolve => setTimeout(resolve, 50)); + + const isTextarea = input.tagName.toLowerCase() === 'textarea'; + const nativeSetter = Object.getOwnPropertyDescriptor( + isTextarea ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype, + 'value' + )?.set; + + if (nativeSetter) { + nativeSetter.call(input, ''); + } + input.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + + if (nativeSetter) { + nativeSetter.call(input, String(skuValue)); + } else { + input.value = String(skuValue); + } + + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + input.dispatchEvent(new InputEvent('input', { + bubbles: true, + cancelable: true, + inputType: 'insertText', + data: String(skuValue) + })); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const saveButton = skuField.querySelector('button.save_q8lO5, button.green_DL05T, button:has(.save), button[class*="save"]'); + if (saveButton) { + saveButton.click(); + saveButton.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + } else { + input.blur(); + document.body.click(); + } + + return { success: true }; +} + +// Guard: only register this listener once per page context +// (executeScript re-injects the file each time the popup opens, +// without the guard we'd accumulate duplicate listeners) +if (!window._discogsTagListenerAdded) { + window._discogsTagListenerAdded = true; + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + // console.log('CONTENT: Received message:', request); + + // NOTE: getInitialValues is handled by the async listener registered earlier + // (inside the window.hasRun block) which properly calls expandAllReviews() + // and extractReviews(). Do NOT handle it here — this sync handler would + // respond first and return empty reviews, stomping the async result. + + if (request.action === 'updateDiscogsPrice') { + updateDiscogsPrice(request.price).then(result => { + console.log('[DEBUG] updateDiscogsPrice result:', result); + sendResponse(result); + }).catch(error => { + console.error('[DEBUG] Error in updateDiscogsPrice:', error); + sendResponse({ success: false, error: error.message }); + }); + return true; // Will respond asynchronously + } + + if (request.action === 'updateDiscogsSku') { + updateDiscogsSkuField(request.sku, request.boxIndex).then(result => { + console.log('[DEBUG] updateDiscogsSkuField result:', result); + sendResponse(result); + }).catch(error => { + console.error('[DEBUG] Error in updateDiscogsSkuField:', error); + sendResponse({ success: false, error: error.message }); + }); + return true; + } + + return true; // Keep the message channel open for async response +}); // end onMessage listener + +} // end _discogsTagListenerAdded guard \ No newline at end of file diff --git a/deepseek.js b/deepseek.js new file mode 100644 index 0000000..6da6616 --- /dev/null +++ b/deepseek.js @@ -0,0 +1,346 @@ +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +function normMarker(v) { + return String(v || '') + .trim() + .replace(/^"+|"+$/g, '') + .replace(/[\s_]+/g, '') + .toUpperCase(); +} + +function isVisible(el) { + if (!el) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + +async function waitForInputEl(timeoutMs) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const named = document.querySelector('textarea[name="search"]'); + if (isVisible(named)) return named; + const els = [ + ...document.querySelectorAll('textarea'), + ...document.querySelectorAll('div[contenteditable="true"]'), + ].filter(isVisible); + const best = els.find(e => (e.getAttribute('aria-label') || '').toLowerCase().includes('message')) + || els.find(e => (e.getAttribute('aria-label') || '').toLowerCase().includes('prompt')) + || els[0]; + if (best) return best; + await sleep(250); + } + return null; +} + +function setInputText(el, text) { + el.focus(); + if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { + const proto = el.tagName === 'TEXTAREA' + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const desc = Object.getOwnPropertyDescriptor(proto, 'value'); + if (desc && desc.set) desc.set.call(el, text); + else el.value = text; + try { + el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertFromPaste', data: text })); + } catch {} + try { + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: text })); + } catch { + el.dispatchEvent(new Event('input', { bubbles: true })); + } + el.dispatchEvent(new Event('change', { bubbles: true })); + return; + } + const editable = el.getAttribute('contenteditable') === 'true'; + if (editable) { + el.focus(); + const sel = window.getSelection && window.getSelection(); + if (sel) { + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + } + try { + document.execCommand('insertText', false, text); + } catch { + el.textContent = text; + } + } else { + el.textContent = text; + } + try { + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: text })); + } catch { + el.dispatchEvent(new Event('input', { bubbles: true })); + } +} + +function isAriaDisabled(el) { + const v = (el?.getAttribute('aria-disabled') || '').toLowerCase(); + return v === 'true'; +} + +function findSendButtonNear(inputEl) { + const containers = []; + let p = inputEl; + for (let i = 0; i < 7 && p; i++) { + if (p.parentElement) containers.push(p.parentElement); + p = p.parentElement; + } + + const selectors = [ + 'div._52c986b[role="button"]', + 'div._52c986b', + 'div.ds-icon-button[role="button"]', + '[role="button"].ds-icon-button', + 'button[type="submit"]', + 'button[aria-label*="Send"]', + 'button[aria-label*="send"]', + ]; + + for (const c of containers) { + for (const sel of selectors) { + const btns = [...c.querySelectorAll(sel)].filter(isVisible); + const usable = btns.filter(b => !isAriaDisabled(b)); + if (usable.length) return usable[usable.length - 1]; + } + } + + const any = [ + ...document.querySelectorAll('div._52c986b[role="button"]'), + ...document.querySelectorAll('div._52c986b'), + ...document.querySelectorAll('div.ds-icon-button[role="button"]'), + ].filter(isVisible); + const usable = any.filter(b => !isAriaDisabled(b)); + return usable.length ? usable[usable.length - 1] : null; +} + +function clickLikeAUser(el) { + if (!el) return; + const r = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + const clientX = r ? Math.round(r.left + r.width / 2) : 1; + const clientY = r ? Math.round(r.top + r.height / 2) : 1; + try { el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX, clientY, pointerType: 'mouse', isPrimary: true })); } catch {} + try { el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX, clientY })); } catch {} + try { el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX, clientY, pointerType: 'mouse', isPrimary: true })); } catch {} + try { el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX, clientY })); } catch {} + try { el.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX, clientY })); } catch {} + try { el.click(); } catch {} +} + +function isGenerating() { + const stop = document.querySelector('[aria-label*="Stop"], [aria-label*="stop"]'); + if (isVisible(stop)) return true; + const progress = document.querySelector('[role="progressbar"]'); + if (isVisible(progress)) return true; + const spinner = document.querySelector('.ds-loading, .loading, [class*="loading"]'); + if (isVisible(spinner)) return true; + return false; +} + +function extractJsonObjects(text, maxObjects = 25) { + if (!text) return []; + const s = String(text); + const out = []; + let i = 0; + while (i < s.length && out.length < maxObjects) { + const start = s.indexOf('{', i); + if (start === -1) break; + let depth = 0; + let inStr = false; + let esc = false; + for (let j = start; j < s.length; j++) { + const ch = s[j]; + if (inStr) { + if (esc) esc = false; + else if (ch === '\\\\') esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') { inStr = true; continue; } + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) { + out.push(s.slice(start, j + 1)); + i = j + 1; + break; + } + } + if (j === s.length - 1) i = s.length; + } + if (i === start) i = start + 1; + } + return out; +} + +function chooseBestJsonFromText(text, marker) { + const markerNorm = normMarker(marker); + const candidates = extractJsonObjects(text, 25); + const expectedKeys = [ + 'summary', + 'store_description', + 'features', + 'sound_profile', + 'highlight_tracks', + 'production', + 'market_insight', + 'sales_angles', + 'embedding', + ]; + + let best = null; + let bestScore = -Infinity; + for (const cand of candidates) { + let parsed; + try { parsed = JSON.parse(cand); } catch { continue; } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue; + + let score = 0; + const eomNorm = normMarker(parsed.eom); + if (markerNorm && eomNorm === markerNorm) score += 200; + + for (const k of expectedKeys) if (Object.prototype.hasOwnProperty.call(parsed, k)) score += 10; + + if (parsed.summary === 'Short factual description (1-2 sentences)') score -= 50; + if (parsed.store_description === 'Punchy, slightly opinionated record store blurb (max 80 words, human tone, not generic AI)') score -= 50; + + if (cand.length > 2000) score += 5; + if (score > bestScore) { bestScore = score; best = parsed; } + } + return best; +} + +function extractLastResponseText() { + const selectors = [ + 'main', + '[role="main"]', + '[class*="chat"]', + ]; + const parts = []; + for (const sel of selectors) { + const el = document.querySelector(sel); + if (!el || !isVisible(el)) continue; + const t = (el.innerText || '').trim(); + if (t.length > 50) parts.push(t); + } + if (!parts.length) return (document.body?.innerText || '').trim() || null; + parts.sort((a, b) => b.length - a.length); + return parts[0]; +} + +async function waitForJsonAndText(marker, timeoutMs) { + const start = Date.now(); + const want = normMarker(marker); + while (Date.now() - start < timeoutMs) { + const txt = extractLastResponseText() || ''; + if (txt) { + const parsed = chooseBestJsonFromText(txt, marker); + if (parsed) { + const got = normMarker(parsed?.eom); + if (!want || got === want) return { text: txt, json: parsed }; + return { text: txt, json: parsed }; + } + if (!isGenerating() && txt.trimStart().startsWith('{')) { + const fallback = chooseBestJsonFromText(txt, marker); + if (fallback) return { text: txt, json: fallback }; + } + } + await sleep(500); + } + const finalTxt = extractLastResponseText(); + return { text: finalTxt, json: chooseBestJsonFromText(finalTxt, marker) || null }; +} + +async function sendToBackground(message) { + return await new Promise(resolve => { + try { + chrome.runtime.sendMessage(message, (resp) => { + const err = chrome.runtime?.lastError?.message; + if (err) resolve({ ok: false, error: err }); + else resolve(resp || { ok: false, error: 'No response' }); + }); + } catch (e) { + resolve({ ok: false, error: e.message || 'sendMessage failed' }); + } + }); +} + +async function run() { + const job = await new Promise(resolve => chrome.storage.local.get(['geminiJob'], r => resolve(r.geminiJob || null))); + if (!job || !job.prompt) return; + const provider = job.provider || 'gemini'; + if (provider !== 'deepseek') return; + if (job.status !== 'pending' && job.status !== 'running') return; + + if (job.status === 'pending') { + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'running', started_at: new Date().toISOString() } }, resolve)); + } + + const inputEl = await waitForInputEl(45000); + if (!inputEl) { + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: 'DeepSeek input box not found.', failed_at: new Date().toISOString() } }, resolve)); + return; + } + + setInputText(inputEl, job.prompt); + await sleep(600); + + const sendBtn = findSendButtonNear(inputEl); + if (!sendBtn) { + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: 'DeepSeek send button not found.', failed_at: new Date().toISOString() } }, resolve)); + return; + } + + try { sendBtn.scrollIntoView({ block: 'center', inline: 'center' }); } catch {} + clickLikeAUser(sendBtn); + + const marker = '__PLICE_EOM__'; + const { text: responseText, json: outputJson } = await waitForJsonAndText(marker, 300000); + if (!responseText) { + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: 'No DeepSeek response detected.', failed_at: new Date().toISOString() } }, resolve)); + return; + } + + const result = { + job_id: job.job_id, + model: job.model || 'deepseek-web', + provider: 'deepseek', + release_id: job.release_id || null, + discogs_url: job.discogs_url || null, + ai_url: location.href, + created_at: job.created_at, + prompt: job.prompt, + input_json: job.input_json || null, + output_text: responseText, + output_json: outputJson, + }; + + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'posting', posting_at: new Date().toISOString() } }, resolve)); + const postResp = await sendToBackground({ action: 'postGeminiResult', result }); + + if (!postResp || !postResp.ok) { + const err = postResp?.error || 'Post failed.'; + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: err, failed_at: new Date().toISOString() }, geminiLastExchange: result }, resolve)); + return; + } + + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'done', done_at: new Date().toISOString() }, geminiLastExchange: result }, resolve)); + + const closeOnSuccess = await new Promise(resolve => + chrome.storage.local.get(['geminiCloseTabOnSuccess'], r => resolve(r.geminiCloseTabOnSuccess !== false)) + ); + if (closeOnSuccess) { + await sendToBackground({ action: 'closeSenderTab' }); + } +} + +if (document.readyState === 'complete' || document.readyState === 'interactive') { + run(); +} else { + window.addEventListener('DOMContentLoaded', () => run(), { once: true }); +} diff --git a/docs/superpowers/plans/2026-04-12-inventory-sku-lookup.md b/docs/superpowers/plans/2026-04-12-inventory-sku-lookup.md new file mode 100644 index 0000000..4d4eeaf --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-inventory-sku-lookup.md @@ -0,0 +1,680 @@ +# Inventory SKU Lookup — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When the popup opens on a Discogs release page with the feature toggled on, automatically SSH-tunnel to the VPS MariaDB, look up inventory SKUs for the release, and fill the Discogs collection SKU field(s) — or show a manual picker if row/box counts don't match. + +**Architecture:** A new `/inventory-lookup` endpoint is added to the existing `rfid-daemon` (port 7790). It opens an SSH tunnel via `ssh2` to `mrpadmin@`, queries `wp_rmp_disc_inventory` via `mysql2`, and returns sorted rows. `popup.js` auto-triggers this on load when enabled, auto-fills matching boxes, or renders a click-to-assign picker on mismatch. + +**Tech Stack:** Node.js (`ssh2`, `mysql2`), Chrome Extension MV3 (`chrome.storage.local`, `chrome.tabs.sendMessage`), existing `updateDiscogsSku` content-script message. + +--- + +## File Map + +| File | Action | What changes | +|---|---|---| +| `rfid-daemon/package.json` | Modify | Add `ssh2`, `mysql2` deps | +| `rfid-daemon/index.js` | Modify | Add `/inventory-lookup` endpoint | +| `popup.html` | Modify | Add inventory settings fields in Connect section + `#inventory-sku-panel` div in Discogs tab | +| `popup.js` | Modify | Save/load `inventorySettings`, auto-trigger `inventorySkuLookup()`, picker render/interaction | + +--- + +## Task 1: Add npm dependencies to rfid-daemon + +**Files:** +- Modify: `rfid-daemon/package.json` + +- [ ] **Step 1: Add ssh2 and mysql2 to package.json** + +Replace the `dependencies` block in `rfid-daemon/package.json`: + +```json +{ + "name": "rfid-daemon", + "version": "1.0.0", + "description": "Local HTTP daemon bridging Chafon H102 UHF RFID reader to browser extension", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2", + "serialport": "^12.0.0", + "ssh2": "^1.16.0", + "mysql2": "^3.9.0" + } +} +``` + +- [ ] **Step 2: Install the new dependencies** + +```bash +cd /Users/jingk/Documents/pliceclogs-ultra-yt-omega-999999-truth/rfid-daemon +npm install +``` + +Expected: `added N packages` with no errors. `node_modules/ssh2` and `node_modules/mysql2` now exist. + +- [ ] **Step 3: Verify imports load without error** + +```bash +cd /Users/jingk/Documents/pliceclogs-ultra-yt-omega-999999-truth/rfid-daemon +node -e "require('ssh2'); require('mysql2/promise'); console.log('OK')" +``` + +Expected output: `OK` + +- [ ] **Step 4: Commit** + +```bash +cd /Users/jingk/Documents/pliceclogs-ultra-yt-omega-999999-truth/rfid-daemon +git add package.json package-lock.json +git commit -m "feat: add ssh2 and mysql2 deps for inventory lookup" +``` + +--- + +## Task 2: Add `/inventory-lookup` endpoint to rfid-daemon + +**Files:** +- Modify: `rfid-daemon/index.js` + +The endpoint receives `release_id`, `dbHost`, `dbName`, `dbUser`, `dbPass`, `sshKeyPath` as query params. It SSH-tunnels to `mrpadmin@dbHost`, connects mysql2 through the tunnel stream, queries `wp_rmp_disc_inventory`, and returns rows sorted ASC by `sku`. + +- [ ] **Step 1: Add require statements at the top of rfid-daemon/index.js** + +After the existing `require` lines at the top of `rfid-daemon/index.js` (after the `const cors = require('cors');` line), add: + +```javascript +const { Client: SshClient } = require('ssh2'); +const mysql = require('mysql2/promise'); +const fs = require('fs'); +``` + +- [ ] **Step 2: Add the inventoryLookup helper function** + +Add this function anywhere before the `app.listen(...)` call at the bottom of `rfid-daemon/index.js`: + +```javascript +// ── Inventory lookup via SSH tunnel ────────────────────────────────────────── + +async function inventoryLookup({ releaseId, dbHost, dbName, dbUser, dbPass, sshKeyPath }) { + const keyPath = (sshKeyPath || '~/.ssh/id_rsa').replace(/^~/, os.homedir()); + + let privateKey; + try { + privateKey = fs.readFileSync(keyPath); + } catch (e) { + throw new Error(`Cannot read SSH key at ${keyPath}: ${e.message}`); + } + + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + + ssh.on('ready', () => { + ssh.forwardOut('127.0.0.1', 0, '127.0.0.1', 3306, async (err, stream) => { + if (err) { ssh.end(); return reject(new Error(`SSH forward failed: ${err.message}`)); } + + try { + const conn = await mysql.createConnection({ + host: '127.0.0.1', + user: dbUser, + password: dbPass, + database: dbName, + stream + }); + + const [rows] = await conn.execute( + 'SELECT sku, price, media_condition, sleeve_condition FROM wp_rmp_disc_inventory WHERE release_id = ? ORDER BY sku ASC', + [parseInt(releaseId, 10)] + ); + + await conn.end(); + ssh.end(); + resolve(rows); + } catch (e) { + ssh.end(); + reject(e); + } + }); + }); + + ssh.on('error', (e) => reject(new Error(`SSH error: ${e.message}`))); + + ssh.connect({ + host: dbHost || '100.123.123.64', + port: 22, + username: 'mrpadmin', + privateKey + }); + }); +} +``` + +- [ ] **Step 3: Add the /inventory-lookup express route** + +Add this route block immediately after the `inventoryLookup` function (still before `app.listen`): + +```javascript +app.get('/inventory-lookup', async (req, res) => { + const { release_id, dbHost, dbName, dbUser, dbPass, sshKeyPath } = req.query; + + if (!release_id) { + return res.status(400).json({ error: 'release_id is required' }); + } + if (!dbUser || !dbPass || !dbName) { + return res.status(400).json({ error: 'dbUser, dbPass, and dbName are required' }); + } + + try { + const rows = await inventoryLookup({ + releaseId: release_id, + dbHost: dbHost || '100.123.123.64', + dbName, + dbUser, + dbPass, + sshKeyPath: sshKeyPath || '~/.ssh/id_rsa' + }); + + res.json({ + rows: rows.map(r => ({ + sku: r.sku, + price: r.price != null ? String(r.price) : null, + media_condition: r.media_condition || null, + sleeve_condition: r.sleeve_condition || null + })) + }); + } catch (e) { + console.error('[inventory-lookup]', e.message); + res.status(500).json({ error: e.message }); + } +}); +``` + +- [ ] **Step 4: Smoke-test the endpoint (daemon must be running)** + +Start the daemon in one terminal: +```bash +cd /Users/jingk/Documents/pliceclogs-ultra-yt-omega-999999-truth/rfid-daemon +node index.js -q +``` + +In another terminal, substitute real values for DB_USER, DB_PASS, DB_NAME, RELEASE_ID: +```bash +curl "http://localhost:7790/inventory-lookup?release_id=RELEASE_ID&dbHost=100.123.123.64&dbName=DB_NAME&dbUser=DB_USER&dbPass=DB_PASS" +``` + +Expected: `{"rows":[...]}` — either an array of objects or an empty array. A JSON error object means SSH/DB credentials are wrong. A connection refused means the daemon isn't running. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/jingk/Documents/pliceclogs-ultra-yt-omega-999999-truth +git add rfid-daemon/index.js +git commit -m "feat: add /inventory-lookup SSH-tunnel endpoint to rfid-daemon" +``` + +--- + +## Task 3: Add settings UI to popup.html + +**Files:** +- Modify: `popup.html` + +Two changes: (A) inventory settings fields inside the Connect collapsible, and (B) a status/picker panel in the Discogs tab. + +- [ ] **Step 1: Add inventory settings fields in the Connect section** + +In `popup.html`, locate the line: +```html + +``` + +Insert the following block **immediately before** that line: + +```html + +
+
Inventory SKU Lookup
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+``` + +- [ ] **Step 2: Add the inventory status and picker panel in the Discogs tab** + +In `popup.html`, locate the line: +```html + +``` + +Insert the following block **immediately after** that line: + +```html + + +``` + +- [ ] **Step 3: Verify HTML is valid — open the extension popup on any Discogs page** + +Load the extension in Chrome (`chrome://extensions` → Load unpacked). Open a Discogs release page, click the extension icon. The popup should open without errors in DevTools console. The Connect settings section should show the new Inventory SKU Lookup fields when expanded. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/jingk/Documents/pliceclogs-ultra-yt-omega-999999-truth +git add popup.html +git commit -m "feat: add inventory SKU lookup settings and picker panel to popup" +``` + +--- + +## Task 4: Wire inventory settings save and load in popup.js + +**Files:** +- Modify: `popup.js` + +- [ ] **Step 1: Add loadInventorySettings function** + +Find the function `loadWpSettings` or `loadGoogleSettings` in `popup.js` (around line 460 or 736). Add the following new function in the same area (after either of those functions): + +```javascript +function loadInventorySettings() { + chrome.storage.local.get(['inventorySettings'], (result) => { + const s = result.inventorySettings || {}; + const el = (id) => document.getElementById(id); + if (el('inventorySkuEnabled')) el('inventorySkuEnabled').checked = !!s.enabled; + if (el('inventory-db-host')) el('inventory-db-host').value = s.dbHost || '100.123.123.64'; + if (el('inventory-db-name')) el('inventory-db-name').value = s.dbName || 'wp_rmp_disc_'; + if (el('inventory-db-user')) el('inventory-db-user').value = s.dbUser || ''; + if (el('inventory-db-pass')) el('inventory-db-pass').value = s.dbPass || ''; + if (el('inventory-ssh-key')) el('inventory-ssh-key').value = s.sshKeyPath || '~/.ssh/id_rsa'; + }); +} +``` + +- [ ] **Step 2: Call loadInventorySettings on popup init** + +Find the lines where `loadWpSettings()` and `loadGoogleSettings()` are called (around line 736-737). Add the new call immediately after them: + +```javascript + loadInventorySettings(); +``` + +- [ ] **Step 3: Add inventory settings save to the save-connect-settings handler** + +Find the end of the `save-connect-settings` click handler. It ends with: +```javascript + } else { + console.log('No Discogs token to save (field empty)'); + } + }); +``` + +Insert the following block **inside** the handler, immediately before the closing `});` of the handler (i.e., after the Discogs token save block): + +```javascript + // Save Inventory SKU Lookup settings + const inventorySettings = { + enabled: document.getElementById('inventorySkuEnabled').checked, + dbHost: document.getElementById('inventory-db-host').value.trim() || '100.123.123.64', + dbName: document.getElementById('inventory-db-name').value.trim() || 'wp_rmp_disc_', + dbUser: document.getElementById('inventory-db-user').value.trim(), + dbPass: document.getElementById('inventory-db-pass').value, + sshKeyPath: document.getElementById('inventory-ssh-key').value.trim() || '~/.ssh/id_rsa' + }; + chrome.storage.local.set({ inventorySettings }, () => { + if (chrome.runtime.lastError) { + console.error('Error saving inventory settings:', chrome.runtime.lastError); + } else { + console.log('Inventory settings saved.'); + } + }); +``` + +- [ ] **Step 4: Manual test — save and reload** + +1. Open the extension popup, go to Settings → Connect (expand it). +2. Fill in the Inventory SKU Lookup fields with test values and check the toggle. +3. Click Save Connect Settings. +4. Close and reopen the popup, go back to Settings → Connect. +5. The fields should be repopulated with the values you entered. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/jingk/Documents/pliceclogs-ultra-yt-omega-999999-truth +git add popup.js +git commit -m "feat: save and load inventorySettings in Connect settings" +``` + +--- + +## Task 5: Auto-trigger inventory lookup on popup load + +**Files:** +- Modify: `popup.js` + +This task adds the `inventorySkuLookup(releaseId)` function and calls it automatically after `fetchAndDisplayReleaseData` completes, using `state.collectionBoxCount` (already populated by that point). + +- [ ] **Step 1: Add the inventorySkuLookup function** + +Add this function near `handleImportInventory` (around line 2092 in `popup.js`): + +```javascript +async function inventorySkuLookup(releaseId) { + const settings = await new Promise(resolve => + chrome.storage.local.get(['inventorySettings'], r => resolve(r.inventorySettings || {})) + ); + + if (!settings.enabled) return; + if (!settings.dbUser || !settings.dbPass || !settings.dbName) return; + + const panel = document.getElementById('inventory-sku-panel'); + const status = document.getElementById('inventory-sku-status'); + if (!panel || !status) return; + + panel.style.display = 'block'; + status.textContent = 'Looking up inventory SKUs…'; + status.style.color = '#888'; + + let rows; + try { + const params = new URLSearchParams({ + release_id: releaseId, + dbHost: settings.dbHost || '100.123.123.64', + dbName: settings.dbName || 'wp_rmp_disc_', + dbUser: settings.dbUser, + dbPass: settings.dbPass, + sshKeyPath: settings.sshKeyPath || '~/.ssh/id_rsa' + }); + const resp = await fetch(`http://localhost:7790/inventory-lookup?${params}`); + const data = await resp.json(); + if (data.error) throw new Error(data.error); + rows = data.rows || []; + } catch (e) { + status.textContent = `Inventory lookup failed: ${e.message}`; + status.style.color = '#c00'; + return; + } + + if (rows.length === 0) { + panel.style.display = 'none'; + return; + } + + const boxCount = state.collectionBoxCount || 1; + const lastBoxIdx = boxCount - 1; + + // ── Auto-fill cases ─────────────────────────────────────────────────────── + if (rows.length === 1) { + // Single row → write to last collection box silently + try { + await chrome.tabs.sendMessage(state.discogsTabId, { + action: 'updateDiscogsSku', + sku: rows[0].sku, + boxIndex: lastBoxIdx + }); + status.textContent = 'SKU filled'; + status.style.color = '#2a7'; + setTimeout(() => { panel.style.display = 'none'; }, 3000); + } catch (e) { + status.textContent = `Failed to fill SKU: ${e.message}`; + status.style.color = '#c00'; + } + return; + } + + if (rows.length === boxCount) { + // Matching counts → fill each box in order (earliest SKU first, already sorted ASC) + let ok = true; + for (let i = 0; i < rows.length; i++) { + try { + await chrome.tabs.sendMessage(state.discogsTabId, { + action: 'updateDiscogsSku', + sku: rows[i].sku, + boxIndex: i + }); + } catch (e) { + ok = false; + status.textContent = `Failed to fill box ${i + 1}: ${e.message}`; + status.style.color = '#c00'; + break; + } + } + if (ok) { + status.textContent = `${rows.length} SKUs filled`; + status.style.color = '#2a7'; + setTimeout(() => { panel.style.display = 'none'; }, 3000); + } + return; + } + + // ── Mismatch → show picker ──────────────────────────────────────────────── + status.textContent = `SKU mismatch: ${rows.length} DB rows, ${boxCount} collection box${boxCount !== 1 ? 'es' : ''}`; + status.style.color = '#b60'; + renderInventoryPicker(rows, boxCount); +} +``` + +- [ ] **Step 2: Call inventorySkuLookup at the end of fetchAndDisplayReleaseData** + +Find this line in `popup.js` (around line 1875): +```javascript + monsterBackgroundSync(releaseId); +``` + +Add the call immediately after it: +```javascript + inventorySkuLookup(releaseId); +``` + +- [ ] **Step 3: Manual test — auto-trigger fires** + +1. Enable the inventory lookup toggle in Settings → Connect and save. +2. Open a Discogs release page and click the extension icon. +3. The popup should briefly show "Looking up inventory SKUs…" in the Discogs tab. +4. If the daemon isn't running: shows "Inventory lookup failed: fetch failed" — expected. +5. If the daemon is running with valid creds: auto-fills or shows mismatch depending on data. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/jingk/Documents/pliceclogs-ultra-yt-omega-999999-truth +git add popup.js +git commit -m "feat: auto-trigger inventory SKU lookup on popup load" +``` + +--- + +## Task 6: Mismatch picker rendering and interaction + +**Files:** +- Modify: `popup.js` + +- [ ] **Step 1: Add the renderInventoryPicker function** + +Add this function immediately after `inventorySkuLookup` in `popup.js`: + +```javascript +function renderInventoryPicker(rows, boxCount) { + const picker = document.getElementById('inventory-sku-picker'); + const cardsEl = document.getElementById('inventory-sku-cards'); + const boxButtonsEl = document.getElementById('inventory-sku-box-buttons'); + + if (!picker || !cardsEl || !boxButtonsEl) return; + + let selectedSku = null; + + // ── Render row cards ────────────────────────────────────────────────────── + cardsEl.innerHTML = ''; + rows.forEach((row) => { + const card = document.createElement('div'); + card.style.cssText = [ + 'padding:5px 8px', + 'border:1px solid #ccc', + 'border-radius:4px', + 'font-size:11px', + 'font-family:monospace', + 'cursor:pointer', + 'background:#fff', + 'display:flex', + 'gap:8px', + 'flex-wrap:wrap' + ].join(';'); + + const price = row.price ? `$${row.price}` : '—'; + const media = row.media_condition || '—'; + const sleeve = row.sleeve_condition || '—'; + card.textContent = `${row.sku} · ${price} · ${media} · ${sleeve}`; + card.dataset.sku = row.sku; + + card.addEventListener('click', () => { + // Deselect all + cardsEl.querySelectorAll('div').forEach(c => { + c.style.borderColor = '#ccc'; + c.style.background = '#fff'; + }); + // Select this card + card.style.borderColor = '#2a7'; + card.style.background = '#f0faf4'; + selectedSku = row.sku; + // Enable box buttons + boxButtonsEl.querySelectorAll('button').forEach(b => { + b.disabled = false; + b.style.opacity = '1'; + }); + }); + + cardsEl.appendChild(card); + }); + + // ── Render box number buttons ───────────────────────────────────────────── + boxButtonsEl.innerHTML = ''; + for (let i = 0; i < boxCount; i++) { + const btn = document.createElement('button'); + btn.textContent = String(i + 1); + btn.disabled = true; // enabled only when a card is selected + btn.style.cssText = [ + 'padding:3px 10px', + 'font-size:12px', + 'border-radius:4px', + 'border:1px solid #aaa', + 'cursor:pointer', + 'opacity:0.4' + ].join(';'); + btn.dataset.boxIndex = String(i); + + btn.addEventListener('click', async () => { + if (!selectedSku) return; + const boxIndex = parseInt(btn.dataset.boxIndex, 10); + + try { + await chrome.tabs.sendMessage(state.discogsTabId, { + action: 'updateDiscogsSku', + sku: selectedSku, + boxIndex + }); + // Visual confirmation on the card + const writtenCard = cardsEl.querySelector(`div[data-sku="${CSS.escape(selectedSku)}"]`); + if (writtenCard) { + writtenCard.style.borderColor = '#2a7'; + writtenCard.style.background = '#e8f8ef'; + writtenCard.textContent = '✓ ' + writtenCard.textContent.replace(/^✓ /, ''); + } + // Mark box button as done + btn.textContent = `✓${i + 1}`; + btn.style.background = '#e8f8ef'; + btn.style.borderColor = '#2a7'; + + // Reset selection so user must pick next card deliberately + selectedSku = null; + cardsEl.querySelectorAll('div').forEach(c => { + c.style.borderColor = '#ccc'; + c.style.background = '#fff'; + }); + boxButtonsEl.querySelectorAll('button').forEach(b => { + b.disabled = true; + b.style.opacity = '0.4'; + }); + } catch (e) { + document.getElementById('inventory-sku-status').textContent = `Write failed: ${e.message}`; + document.getElementById('inventory-sku-status').style.color = '#c00'; + } + }); + + boxButtonsEl.appendChild(btn); + } + + picker.style.display = 'block'; +} +``` + +- [ ] **Step 2: Manual test — mismatch picker** + +To test the picker without needing a real mismatch from the DB, temporarily add this call at the bottom of `inventorySkuLookup`, right before the `renderInventoryPicker` call, to force the mismatch branch: + +Trigger the mismatch branch by temporarily setting a release that has a different number of DB rows than collection boxes, or by editing the condition `rows.length === boxCount` to always be false for testing. Verify: + +1. The mismatch message appears: e.g. `SKU mismatch: 2 DB rows, 1 collection box` +2. Row cards are shown with SKU, price, media condition, sleeve condition +3. Box number buttons are grayed out initially +4. Clicking a card highlights it green and enables box buttons +5. Clicking a box number button writes the SKU (check Discogs page), marks the card with ✓, marks the button with ✓N +6. Buttons gray out again — user must select next card before writing to another box +7. Remove any temporary test overrides after confirming behaviour + +- [ ] **Step 3: End-to-end test with real data** + +1. Ensure rfid-daemon is running (`node rfid-daemon/index.js -q`) +2. Ensure inventory settings are saved with valid DB creds +3. Open a Discogs release page that **is** in your collection +4. Click the extension icon + + **Case A — matching counts:** SKUs should fill silently, `"N SKUs filled"` appears briefly then hides. + + **Case B — 1 row:** SKU fills into the last collection box silently. + + **Case C — mismatch:** Picker appears. Assign manually. + + **Case D — 0 rows:** Nothing shown (panel stays hidden). + +- [ ] **Step 4: Commit** + +```bash +cd /Users/jingk/Documents/pliceclogs-ultra-yt-omega-999999-truth +git add popup.js +git commit -m "feat: inventory SKU mismatch picker with card select and box-number assignment" +``` diff --git a/docs/superpowers/specs/2026-04-12-inventory-sku-lookup-design.md b/docs/superpowers/specs/2026-04-12-inventory-sku-lookup-design.md new file mode 100644 index 0000000..a5ab8bf --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-inventory-sku-lookup-design.md @@ -0,0 +1,167 @@ +# Inventory SKU Lookup — Design Spec +**Date:** 2026-04-12 + +## Overview + +When the popup opens on a Discogs release page and the feature is enabled, it SSHes (via Tailscale) to the inventory MariaDB, looks up SKUs for the current `release_id`, and auto-fills the SKU custom collection field(s) on the Discogs page. If there is a row/box count mismatch, a picker UI lets the user manually assign each SKU to the correct collection box. + +--- + +## Database Schema + +Table: `wp_rmp_disc_inventory` (on MariaDB at `100.123.123.64`) + +| Column | Type | Notes | +|---|---|---| +| `sku` | varchar(255) PK | Timestamp-format integer e.g. `20250515100402` — lower = older | +| `release_id` | int(11) INDEX | Discogs release ID | +| `media_condition` | text nullable | | +| `sleeve_condition` | text nullable | | +| `price` | decimal(10,2) | May drift vs Discogs — display only, never written to Discogs | + +Query: +```sql +SELECT sku, price, media_condition, sleeve_condition +FROM wp_rmp_disc_inventory +WHERE release_id = ? +ORDER BY sku ASC +``` + +--- + +## Architecture + +``` +Discogs page (content.js) + ↕ sendMessage +Popup (popup.js) + ↕ fetch localhost:7790 +rfid-daemon (index.js) ──ssh2──▶ mrpadmin@100.123.123.64 + ──mysql2─▶ MariaDB 127.0.0.1:3306 (via tunnel) +``` + +The existing `updateDiscogsSku` message (`chrome.tabs.sendMessage`) already handles writing a SKU value to a specific collection box index — no changes needed to `content.js`. + +`state.collectionBoxCount` and `state.collectionBoxSkus` are already tracked in popup.js and populated when the popup loads on a Discogs release page. + +**Distinct from existing MONSTERWIKI inventory import (`handleImportInventory` / INV button):** that feature talks to the Python server on `100.91.239.7:5002` (PostgreSQL, different dataset) and is triggered manually. This new feature is auto-triggered on popup open, talks to the WordPress MariaDB at `100.123.123.64`, and only writes SKU (not price). + +--- + +## 1. Settings UI (Connect section, popup.html) + +Add a new collapsible sub-section "Inventory SKU Lookup" inside the existing Connect settings block. + +**Fields:** + +| Field | Element ID | Default | +|---|---|---| +| Enable toggle | `inventorySkuEnabled` (checkbox) | unchecked | +| DB Host | `inventory-db-host` | `100.123.123.64` | +| DB Name | `inventory-db-name` | `wp_rmp_disc_` | +| DB User | `inventory-db-user` | *(empty)* | +| DB Password | `inventory-db-pass` (type=password) | *(empty)* | +| SSH Key Path | `inventory-ssh-key` | `~/.ssh/id_rsa` | + +Saved and loaded by the existing "Save Connect Settings" button alongside Discogs token, Google settings, etc. Values stored in `chrome.storage.local` under key `inventorySettings`. + +--- + +## 2. rfid-daemon: `/inventory-lookup` endpoint + +### New npm dependencies +- `ssh2` — SSH tunnel +- `mysql2` — MariaDB client (promise API) + +### Endpoint + +``` +GET /inventory-lookup?release_id=&dbHost=&dbName=&dbUser=&dbPass=&sshKeyPath= +``` + +### Behaviour + +1. Read SSH private key from `sshKeyPath` on disk (resolved from `~` to `os.homedir()`) +2. Open SSH connection to `mrpadmin@dbHost` using the private key +3. Forward a random local port → `127.0.0.1:3306` on the remote host via `ssh2` `forwardOut` +4. Connect `mysql2` to `127.0.0.1:` with `dbUser`, `dbPass`, `dbName` +5. Execute query with `release_id` as the bound parameter +6. Close tunnel and DB connection +7. Return: +```json +{ "rows": [ { "sku": "20250515100402", "price": "12.00", "media_condition": "VG+", "sleeve_condition": "VG" } ] } +``` + +Errors return `{ "error": "" }` with HTTP 500. + +The tunnel is opened fresh per request (no persistent connection) — requests are infrequent (one per popup open). + +--- + +## 3. Popup logic (popup.js) + +### Trigger +Runs automatically when the popup finishes loading on a Discogs release page (`state.discogsTabId` is set and `state.releaseData.id` exists) and `inventorySettings.enabled` is `true`. + +### Flow + +``` +load inventorySettings from chrome.storage.local +if not enabled → stop + +GET /inventory-lookup?release_id=&...creds... +if error → show error message in popup, stop + +count rows = result.rows.length +count boxes = number of collection boxes on page (state.collectionBoxCount or equivalent) + +if rows == 0 → silent, stop +if rows == 1 → writeSkuToBox(rows[0].sku, state.collectionBoxCount - 1) [silent, last box] +if rows == boxes → for each i: writeSkuToBox(rows[i].sku, i) [silent, earliest SKU → first box] +else → show mismatch picker UI +``` + +### writeSkuToBox(sku, boxIndex) +Calls `chrome.tabs.sendMessage(state.discogsTabId, { action: 'updateDiscogsSku', sku, boxIndex })` — existing content.js handler, no changes needed. + +--- + +## 4. Mismatch Picker UI + +Rendered inside the popup when `rows ≠ boxes` (and not the 1-row case). + +**Layout:** + +``` +⚠ SKU mismatch: 2 DB rows, 3 collection boxes + +[ 20250515100402 · $12.00 · VG+ · VG ] ← clickable card +[ 20260101090000 · $18.50 · NM · NM ] ← clickable card + +Write selected to box: [1] [2] [3] +``` + +**Interaction:** +- Clicking a card selects it (highlighted border/background), deselects others +- The box number buttons are always visible (one per collection box, labelled 1-based) +- Clicking a box number button calls `writeSkuToBox(selectedSku, boxIndex)` for the selected card +- After writing, that card gets a small checkmark indicator; user can continue selecting and writing other rows +- No card selected + box button click → no-op (button visually disabled until a card is selected) + +--- + +## 5. Error / Status Display + +A small status line is added to the Discogs tab in the popup (below existing content) to show: +- Nothing (default) +- `"SKU mismatch: N rows, M boxes"` — triggers picker +- `"Inventory lookup failed: "` — SSH/DB error +- `"SKUs filled"` — after successful auto-fill (fades after 3s) + +--- + +## Out of Scope + +- Writing price to Discogs (price may drift — display only in picker) +- Caching DB results between popup opens +- Handling multiple Discogs tabs simultaneously diff --git a/fonts/NotoSans-Regular.ttf b/fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000..d552209 Binary files /dev/null and b/fonts/NotoSans-Regular.ttf differ diff --git a/gemini.js b/gemini.js new file mode 100644 index 0000000..63c2c3e --- /dev/null +++ b/gemini.js @@ -0,0 +1,513 @@ +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +async function saveDebug(data) { + try { + await new Promise(resolve => chrome.storage.local.set({ geminiDebugLast: { ...data, at: new Date().toISOString() } }, resolve)); + } catch {} +} + +function normMarker(v) { + return String(v || '') + .trim() + .replace(/^"+|"+$/g, '') + .replace(/[\s_]+/g, '') + .toUpperCase(); +} + +function isVisible(el) { + if (!el) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + +async function waitForSelectorAny(selectors, timeoutMs) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + for (const sel of selectors) { + const el = document.querySelector(sel); + if (isVisible(el)) return el; + } + await sleep(250); + } + return null; +} + +async function waitForInputEl(timeoutMs) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const els = [ + ...document.querySelectorAll('textarea'), + ...document.querySelectorAll('div[contenteditable="true"]'), + ].filter(isVisible); + const best = els.find(e => (e.getAttribute('aria-label') || '').toLowerCase().includes('prompt')) + || els.find(e => (e.getAttribute('aria-label') || '').toLowerCase().includes('message')) + || els[0]; + if (best) return best; + await sleep(250); + } + return null; +} + +function setInputText(el, text) { + el.focus(); + if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { + const proto = el.tagName === 'TEXTAREA' + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const desc = Object.getOwnPropertyDescriptor(proto, 'value'); + if (desc && desc.set) desc.set.call(el, text); + else el.value = text; + try { + el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertFromPaste', data: text })); + } catch {} + try { + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: text })); + } catch { + el.dispatchEvent(new Event('input', { bubbles: true })); + } + el.dispatchEvent(new Event('change', { bubbles: true })); + return; + } + const editable = el.getAttribute('contenteditable') === 'true'; + if (editable) { + el.focus(); + const sel = window.getSelection && window.getSelection(); + if (sel) { + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + } + try { + document.execCommand('insertText', false, text); + } catch { + el.textContent = text; + } + } else { + el.textContent = text; + } + try { + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: text })); + } catch { + el.dispatchEvent(new Event('input', { bubbles: true })); + } +} + +function findSendButton() { + const selectors = [ + 'button[type="submit"]', + 'button[aria-label="Send message"]', + 'button[aria-label="Send"]', + 'button[aria-label*="Send"]', + 'button[aria-label*="send"]', + 'button[aria-label*="Submit"]', + 'button[aria-label*="submit"]', + 'button[data-testid*="send"]', + 'button.send-button', + 'button.submit', + ]; + for (const sel of selectors) { + const btns = [...document.querySelectorAll(sel)]; + for (const btn of btns) { + if (isVisible(btn)) return btn; + } + } + const btns = [...document.querySelectorAll('button')].filter(isVisible); + const labeled = btns.filter(b => { + const label = `${b.getAttribute('aria-label') || ''} ${b.getAttribute('title') || ''} ${b.textContent || ''}`.toLowerCase(); + return label.includes('send message') || label === 'send' || label.includes(' send ') || label.includes('submit'); + }); + if (labeled.length) return labeled[labeled.length - 1]; + return null; +} + +function triggerEnter(el) { + const down = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter' }); + const up = new KeyboardEvent('keyup', { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter' }); + el.dispatchEvent(down); + el.dispatchEvent(up); +} + +function isAriaDisabled(el) { + const v = (el?.getAttribute('aria-disabled') || '').toLowerCase(); + return v === 'true'; +} + +function clickLikeAUser(el) { + if (!el) return; + const r = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + const clientX = r ? Math.round(r.left + r.width / 2) : 1; + const clientY = r ? Math.round(r.top + r.height / 2) : 1; + try { el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX, clientY, pointerType: 'mouse', isPrimary: true })); } catch {} + try { el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX, clientY })); } catch {} + try { el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX, clientY, pointerType: 'mouse', isPrimary: true })); } catch {} + try { el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX, clientY })); } catch {} + try { el.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX, clientY })); } catch {} + try { el.click(); } catch {} +} + +function findSendButtonNear(inputEl) { + const containers = []; + const form = inputEl?.closest && inputEl.closest('form'); + if (form) containers.push(form); + let p = inputEl; + for (let i = 0; i < 6 && p; i++) { + if (p.parentElement) containers.push(p.parentElement); + p = p.parentElement; + } + + const selectors = [ + 'button[aria-label="Send message"]', + 'button.send-button', + 'button.submit', + 'button[type="submit"]', + ]; + + for (const c of containers) { + for (const sel of selectors) { + const btns = [...c.querySelectorAll(sel)].filter(isVisible); + if (btns.length) return btns[btns.length - 1]; + } + } + return findSendButton(); +} + +async function waitForSendEnabled(inputEl, timeoutMs) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const btn = findSendButtonNear(inputEl); + if (btn && isVisible(btn) && !btn.disabled && !isAriaDisabled(btn)) return btn; + await sleep(250); + } + return findSendButtonNear(inputEl); +} + +async function sendMessageNow(inputEl) { + for (let i = 0; i < 3; i++) { + const sendBtn = await waitForSendEnabled(inputEl, 4000); + await saveDebug({ + attempt: i + 1, + hasSendBtn: !!sendBtn, + sendBtnDisabled: !!sendBtn?.disabled, + sendBtnAriaDisabled: (sendBtn?.getAttribute && sendBtn.getAttribute('aria-disabled')) || null, + sendBtnLabel: (sendBtn?.getAttribute && sendBtn.getAttribute('aria-label')) || null, + }); + if (sendBtn && !sendBtn.disabled && !isAriaDisabled(sendBtn)) { + try { sendBtn.scrollIntoView({ block: 'center', inline: 'center' }); } catch {} + clickLikeAUser(sendBtn); + await sleep(600); + if (isGenerating()) return true; + const form = sendBtn.closest && sendBtn.closest('form'); + if (form && form.requestSubmit) { + try { form.requestSubmit(); } catch {} + await sleep(600); + if (isGenerating()) return true; + } + } + triggerEnter(inputEl); + await sleep(600); + if (isGenerating()) return true; + try { + inputEl.dispatchEvent(new Event('input', { bubbles: true })); + } catch {} + await sleep(250); + } + return false; +} + +function isGenerating() { + const stopBtn = document.querySelector('button[aria-label*="Stop"], button[aria-label*="stop"]'); + if (isVisible(stopBtn)) return true; + const progress = document.querySelector('[role="progressbar"]'); + if (isVisible(progress)) return true; + return false; +} + +function extractJsonObjects(text, maxObjects = 20) { + if (!text) return []; + const s = String(text); + const out = []; + let i = 0; + while (i < s.length && out.length < maxObjects) { + const start = s.indexOf('{', i); + if (start === -1) break; + let depth = 0; + let inStr = false; + let esc = false; + for (let j = start; j < s.length; j++) { + const ch = s[j]; + if (inStr) { + if (esc) esc = false; + else if (ch === '\\\\') esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') { inStr = true; continue; } + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) { + out.push(s.slice(start, j + 1)); + i = j + 1; + break; + } + } + if (j === s.length - 1) i = s.length; + } + if (i === start) i = start + 1; + } + return out; +} + +function chooseBestJsonFromText(text, marker) { + const markerNorm = normMarker(marker); + const candidates = extractJsonObjects(text, 25); + const expectedKeys = [ + 'summary', + 'store_description', + 'features', + 'sound_profile', + 'highlight_tracks', + 'production', + 'market_insight', + 'sales_angles', + 'embedding', + ]; + + let best = null; + let bestScore = -Infinity; + for (const cand of candidates) { + let parsed; + try { parsed = JSON.parse(cand); } catch { continue; } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue; + + let score = 0; + const eomNorm = normMarker(parsed.eom); + if (markerNorm && eomNorm === markerNorm) score += 200; + + for (const k of expectedKeys) if (Object.prototype.hasOwnProperty.call(parsed, k)) score += 10; + + const placeholderSummary = 'Short factual description (1-2 sentences)'; + const placeholderStore = 'Punchy, slightly opinionated record store blurb (max 80 words, human tone, not generic AI)'; + if (parsed.summary === placeholderSummary) score -= 50; + if (parsed.store_description === placeholderStore) score -= 50; + + if (parsed.embedding && typeof parsed.embedding === 'object') score += 5; + if (parsed.embedding?.description && typeof parsed.embedding.description === 'string') score += 5; + if (parsed.embedding?.vibe && typeof parsed.embedding.vibe === 'string') score += 5; + if (parsed.embedding?.sales && typeof parsed.embedding.sales === 'string') score += 5; + + if (cand.length > 2000) score += 5; + + if (score > bestScore) { + bestScore = score; + best = parsed; + } + } + return best; +} + +function extractLastResponseText() { + const selectors = [ + 'main div[class*="markdown"]', + 'main div[class*="response"]', + 'main div[class*="model"]', + 'main [role="article"]', + 'main article', + ]; + const seen = new Set(); + const nodes = []; + for (const sel of selectors) { + for (const el of document.querySelectorAll(sel)) { + if (!isVisible(el)) continue; + const txt = (el.innerText || '').trim(); + if (txt.length < 40) continue; + if (seen.has(txt)) continue; + seen.add(txt); + nodes.push({ el, txt }); + } + } + if (!nodes.length) { + const main = document.querySelector('main'); + const txt = (main?.innerText || '').trim(); + return txt.length ? txt : null; + } + const scored = nodes.map(n => { + const t = n.txt; + let s = t.length / 1000; + if (t.includes('__PLICE_EOM__')) s += 50; + if (t.trimStart().startsWith('{')) s += 10; + if (t.includes('You said')) s -= 20; + if (t.includes('INPUT JSON:')) s -= 10; + if (t.includes('OUTPUT SCHEMA:')) s -= 10; + if (t.includes('Gemini is AI and can make mistakes')) s -= 5; + return { ...n, score: s }; + }).sort((a, b) => b.score - a.score); + return scored[0].txt; +} + +async function waitForStableResponse(timeoutMs) { + const start = Date.now(); + let last = null; + let stable = 0; + while (Date.now() - start < timeoutMs) { + const txt = extractLastResponseText(); + const gen = isGenerating(); + if (txt && txt === last) stable += 1; + else stable = 0; + last = txt; + if (txt && txt.includes('__PLICE_EOM__') && !gen && stable >= 2) return txt; + if (txt && !gen && stable >= 6) return txt; + await sleep(500); + } + return extractLastResponseText(); +} + +function parseJsonMaybe(text) { + if (!text) return null; + const trimmed = text.trim(); + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start === -1 || end === -1 || end <= start) return null; + const candidate = trimmed.slice(start, end + 1); + try { return JSON.parse(candidate); } catch { return null; } +} + +function findJsonWithMarker(text, marker) { + if (!text || !marker) return null; + const i = text.indexOf(marker); + if (i === -1) return null; + const before = text.lastIndexOf('{', i); + const after = text.indexOf('}', i); + if (before === -1 || after === -1 || after <= before) return null; + const candidate = text.slice(before, after + 1); + try { return JSON.parse(candidate); } catch { return null; } +} + +async function waitForJsonAndText(marker, timeoutMs) { + const start = Date.now(); + const want = normMarker(marker); + while (Date.now() - start < timeoutMs) { + const txt = extractLastResponseText() || ''; + if (txt) { + const parsed = chooseBestJsonFromText(txt, marker); + if (parsed) { + const got = normMarker(parsed?.eom); + if (!want || got === want) return { text: txt, json: parsed }; + } + if (marker && txt.includes(marker) && !isGenerating()) return { text: txt, json: parsed || chooseBestJsonFromText(txt, marker) || parseJsonMaybe(txt) }; + } + await sleep(500); + } + const finalTxt = extractLastResponseText(); + return { text: finalTxt, json: chooseBestJsonFromText(finalTxt, marker) || parseJsonMaybe(finalTxt) }; +} + +async function sendToBackground(message) { + return await new Promise(resolve => { + try { + chrome.runtime.sendMessage(message, (resp) => { + const err = chrome.runtime?.lastError?.message; + if (err) resolve({ ok: false, error: err }); + else resolve(resp || { ok: false, error: 'No response' }); + }); + } catch (e) { + resolve({ ok: false, error: e.message || 'sendMessage failed' }); + } + }); +} + +async function run() { + const job = await new Promise(resolve => chrome.storage.local.get(['geminiJob'], r => resolve(r.geminiJob || null))); + if (!job || !job.prompt || (job.status !== 'pending' && job.status !== 'running')) return; + + // If the job is older than 15 minutes and still pending/running, it's stale — abandon it + // so it doesn't re-inject text into Gemini on every page load. + const jobAge = job.created_at ? (Date.now() - new Date(job.created_at).getTime()) : Infinity; + if (jobAge > 15 * 60 * 1000) { + await new Promise(resolve => chrome.storage.local.set({ + geminiJob: { ...job, status: 'error', error: 'Job abandoned (stale — older than 15 minutes)', failed_at: new Date().toISOString() } + }, resolve)); + return; + } + + if (job.status === 'pending') { + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'running', started_at: new Date().toISOString() } }, resolve)); + } + + const inputEl = await waitForInputEl(45000); + if (!inputEl) { + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: 'Gemini input box not found.', failed_at: new Date().toISOString() } }, resolve)); + return; + } + + setInputText(inputEl, job.prompt); + await sleep(500); + const sent = await sendMessageNow(inputEl); + if (!sent) { + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: 'Failed to trigger Gemini send.', failed_at: new Date().toISOString() } }, resolve)); + return; + } + + const marker = '__PLICE_EOM__'; + const { text: responseText, json: parsedFromWait } = await waitForJsonAndText(marker, 300000); + if (!responseText) { + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: 'No Gemini response detected.', failed_at: new Date().toISOString() } }, resolve)); + return; + } + + let outputJson = parsedFromWait || parseJsonMaybe(responseText); + if (!outputJson) outputJson = findJsonWithMarker(responseText, marker); + + await saveDebug({ + stage: 'captured', + response_chars: responseText.length, + has_json: !!outputJson, + has_marker_text: responseText.includes(marker), + eom_value: outputJson ? outputJson.eom : null, + }); + + const result = { + job_id: job.job_id, + model: job.model || 'gemini-web', + release_id: job.release_id || null, + discogs_url: job.discogs_url || null, + gemini_url: location.href, + created_at: job.created_at, + prompt: job.prompt, + input_json: job.input_json || null, + output_text: responseText, + output_json: outputJson, + }; + + try { + await navigator.clipboard.writeText(job.prompt + '\n\n' + responseText); + } catch {} + + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'posting', posting_at: new Date().toISOString() } }, resolve)); + const postResp = await sendToBackground({ action: 'postGeminiResult', result }); + await saveDebug({ stage: 'posted', postResp }); + + if (!postResp || !postResp.ok) { + const err = postResp?.error || 'Post failed.'; + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: err, failed_at: new Date().toISOString() }, geminiLastExchange: result }, resolve)); + return; + } + + await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'done', done_at: new Date().toISOString() }, geminiLastExchange: result }, resolve)); + + const closeOnSuccess = await new Promise(resolve => + chrome.storage.local.get(['geminiCloseTabOnSuccess'], r => resolve(r.geminiCloseTabOnSuccess !== false)) + ); + if (closeOnSuccess) { + await sendToBackground({ action: 'closeSenderTab' }); + } +} + +if (document.readyState === 'complete' || document.readyState === 'interactive') { + run(); +} else { + window.addEventListener('DOMContentLoaded', () => run(), { once: true }); +} diff --git a/label.css b/label.css new file mode 100644 index 0000000..cb3775d --- /dev/null +++ b/label.css @@ -0,0 +1,92 @@ +.label-field { + position: absolute; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + box-sizing: border-box; +} + +.print-logo img, +.print-logo-large img { + width: 100%; + height: 100%; + object-fit: contain; + image-rendering: -webkit-optimize-contrast; +} + +.print-barcode img, +.print-barcode-large img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.print-title, +.print-genre, +.print-style, +.print-description, +.print-bottom-info, +.print-title-large, +.print-genre-large, +.print-style-large, +.print-description-large, +.print-bottom-info-large { + display: flex; + align-items: flex-end; +} + +/* Buffer genre/style away from the QR code on the right */ +.print-genre, .print-genre-large, +.print-style, .print-style-large { + padding-right: 1.5mm; + box-sizing: border-box; +} + +/* Bottom info: pipe separators via CSS */ +.print-bottom-info .country::before, +.print-bottom-info .label::before, +.print-bottom-info-large .country::before, +.print-bottom-info-large .label::before { + content: ' | '; + white-space: pre; +} + +/* Bottom info: year is fixed-width when present */ +.print-bottom-info .year, +.print-bottom-info-large .year { + flex-shrink: 0; +} + +/* Bottom info: country and label auto-fit */ +.print-bottom-info .country, +.print-bottom-info-large .country { + flex-shrink: 0; +} + +.print-bottom-info .label, +.print-bottom-info-large .label { + flex: 1; + min-width: 0; +} + +/* When no year: hide year span and remove country's leading pipe */ +.print-bottom-info.no-year .year, +.print-bottom-info-large.no-year .year { + display: none; +} + +.print-bottom-info.no-year .country::before, +.print-bottom-info-large.no-year .country::before { + content: none; +} + +.print-price, +.print-condition, +.print-barcode, +.print-price-large, +.print-condition-large, +.print-barcode-large { + display: flex; + align-items: center; + justify-content: right; +} \ No newline at end of file diff --git a/large.css b/large.css new file mode 100644 index 0000000..d146665 --- /dev/null +++ b/large.css @@ -0,0 +1,1152 @@ +/* Large Label Preview */ +.label-preview-large { + font-family: "MS Gothic", "Hiragino Kaku Gothic Pro", Arial, sans-serif; + width: 54mm; + height: 24mm; + position: relative; + background: white; + overflow: hidden; + margin: 0; + padding: 0; + border: 0.1mm solid black; +} + +.print-label-large { + font-family: "MS Gothic", "Hiragino Kaku Gothic Pro", Arial, sans-serif; + width: 54mm !important; + height: 24mm; + position: absolute; + background: white; + overflow: hidden; + top: 3mm; + margin-left: 2mm !important; + padding: 0 !important; + border: 0; + display: block !important; + box-sizing: border-box !important; + transform: none !important; + float: none !important; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + +} + +/* Large Label Field Positions */ +.print-artist-large { + left: 0mm; + top: 0mm; + width: 40mm; + height: 4mm; + font-size: 8pt; +} + +.print-title-large { + left: 0mm; + top: 4mm; + width: 40mm; + height: 4mm; + font-size: 8pt; +} + +.print-genre-large { + left: 0mm; + top: 8mm; + width: 40mm; + height: 4mm; + font-size: 8pt; +} + +.print-style-large { + left: 0mm; + top: 12mm; + width: 40mm; + height: 4mm; + font-size: 9pt; + font-weight: bold; +} + +.print-description-large { + left: 0mm; + top: 16mm; + width: 40mm; + height: 3mm; + font-size: 6pt; +} + +.print-bottom-info-large { + left: 0mm; + top: 19mm; + width: 40mm; + height: 3mm; + gap: 0; + /* Remove gap between elements */ +} + +/* Add specific styles for the child elements */ +.print-bottom-info-large .year { + width: auto; + font-size: 7pt !important; + margin: 0; + padding: 0; +} + +.print-bottom-info-large .country { + width: auto; + min-width: 4mm; + line-height: 1; + margin: 0; + padding: 0; +} + +.print-bottom-info-large .label { + flex: 1; + text-align: left; + margin: 0; + padding: 0; +} + +.print-logo-large { + position: absolute; + left: 40mm; + top: 0mm; + width: 11mm; + height: 2.5mm; + display: block; +} + +.print-price-large { + position: absolute; + left: 40mm; + top: 2.5mm; + width: 11mm; + height: 5.5mm; + font-size: 13pt; + font-weight: bold; +} + +.print-condition-large { + position: absolute; + left: 40mm; + top: 8mm; + width: 11mm; + height: 2.5mm; + font-size: 6pt; + line-height: 1; +} + + +.print-barcode-large { + position: absolute; + left: 40mm; + top: 10.5mm; + width: 11mm; + height: 11mm; +} + +/* Comment and Notes — no room on large label by default; adjust positions to fit your redesign */ +.print-comment-large { + left: 0mm; + top: 22mm; + width: 40mm; + height: 2mm; + font-size: 5pt; + overflow: hidden; +} + +.print-notes-large { + left: 0mm; + top: 24mm; + width: 40mm; + height: 1.5mm; + font-size: 4.5pt; + overflow: hidden; +} + +/* Large Label Print Settings */ +@media print { + .print-label-large { + width: 54mm !important; + height: 24mm !important; + + } +} + +@page large-label { + size: 54mm 24mm; + margin: 0; + padding: 0; +} + +/* Tab Styles */ +.tab-container { + width: 100%; + margin-bottom: 10px; +} + +.tab-nav { + display: flex; + border-bottom: 2px solid #ddd; + background: #f5f5f5; +} + +.tab-button { + flex: 1; + padding: 12px 16px; + border: none; + background: #f5f5f5; + cursor: pointer; + font-size: 14px; + font-weight: 500; + color: #666; + border-bottom: 3px solid transparent; + transition: all 0.3s ease; +} + +.tab-button:hover { + background: #e9e9e9; + color: #333; +} + +.tab-button.active { + background: white; + color: #007cba; + border-bottom-color: #007cba; +} + +.tab-content { + display: none; + padding: 20px; + background: white; + min-height: 400px; +} + +.tab-content.active { + display: block; +} + +/* Layout Styles - Ultra Compact */ +.container { + display: flex; + gap: 4px; + max-width: 700px; + width: 100%; + padding: 2px; +} + +.left-column { + flex: 1; + min-width: 260px; +} + +.right-column-combined { + flex: 1; + min-width: 260px; +} + +/* Enhanced Button Styling */ +.primary-buttons { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 4px; +} + +.primary-buttons-large { + display: flex; + gap: 3px; +} + +.primary-buttons-small { + display: flex; + gap: 3px; +} + +.action-button-small { + padding: 2px 8px !important; + font-size: 10px !important; + flex: 1; +} + +.action-button { + background: linear-gradient(135deg, #2196F3, #1976D2); + color: white; + border: none; + padding: 6px 8px; + font-size: 11px; + font-weight: 600; + border-radius: 4px; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: all 0.3s ease; + flex: 1; + min-width: 50px; + text-transform: uppercase; +} + +.action-button:hover { + background: linear-gradient(135deg, #1976D2, #1565C0); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +/* Special styling for ALL button */ +.all-button { + background: linear-gradient(135deg, #4CAF50, #45a049); +} + +.all-button:hover { + background: linear-gradient(135deg, #45a049, #3d8b40); +} + +/* General Interface Styling - Ultra Compact */ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + margin: 0; + padding: 0; + font-size: 12px; +} + +.input-group { + margin-bottom: 2px; + padding: 2px; + background: white; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.input-group label { + font-weight: 600; + color: #333; + margin-bottom: 0px; + display: block; + font-size: 10px; +} + +.release-info { + background: white; + padding: 4px; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + margin-bottom: 4px; +} + +.release-info h3 { + color: #2c3e50; + margin-top: 0; + margin-bottom: 2px; + font-size: 11px; + border-bottom: 1px solid #3498db; + padding-bottom: 1px; + font-weight: 600; +} + +.info-field { + margin-bottom: 1px; + padding: 1px 3px; + background: #f8f9fa; + border-radius: 1px; + border-left: 1px solid #3498db; + font-size: 9px; + line-height: 1.1; +} + +.info-field strong { + color: #2c3e50; +} + +/* Price Options Styling - Ultra Snug Grid */ +.price-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(32px, 1fr)); + gap: 1px; + margin-top: 0px; +} + +.price-options label { + display: flex; + align-items: center; + justify-content: center; + padding: 1px; + background: #e3f2fd; + border-radius: 1px; + cursor: pointer; + transition: all 0.3s ease; + border: 1px solid transparent; + font-size: 9px; + font-weight: 600; + min-height: 18px; +} + +.price-options label:hover { + background: #bbdefb; + border-color: #2196F3; +} + +.price-options input[type="checkbox"]:checked+.price-label { + background: #2196F3; + color: white; +} + +/* Ultra compact input styling */ +input[type="text"], +input[type="number"], +select, +textarea { + font-size: 9px; + padding: 1px; + border-radius: 2px; + border: 1px solid #ddd; + margin-bottom: 1px; +} + +/* Price input field */ +input[type="text"] { + width: 100%; + margin-bottom: 1px; +} + +/* YouTube Cookie Textarea - Compact */ +#cookieOutput { + width: 100%; + height: 100px; + min-height: 100px; + padding: 6px; + border: 1px solid #ddd; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 10px; + background: #f8f9fa; + resize: vertical; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.youtube-cookie-container { + background: white; + padding: 8px; + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.youtube-cookie-container h3 { + color: #2c3e50; + margin-top: 0; + margin-bottom: 8px; + font-size: 14px; + text-align: center; + border-bottom: 2px solid #e74c3c; + padding-bottom: 4px; +} + +.youtube-extract-btn { + background: linear-gradient(135deg, #e74c3c, #c0392b); + color: white; + border: none; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + border-radius: 4px; + cursor: pointer; + margin-bottom: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all 0.3s ease; + width: 100%; +} + +.youtube-extract-btn:hover { + background: linear-gradient(135deg, #c0392b, #a93226); + transform: translateY(-1px); + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); +} + +/* Settings Styling - Ultra Compact */ +.settings-container { + background: white; + margin-bottom: 8px; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.settings-container button { + width: 100%; + padding: 8px 10px; + background: linear-gradient(135deg, #9b59b6, #8e44ad); + color: white; + border: none; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.settings-container button:hover { + background: linear-gradient(135deg, #8e44ad, #7d3c98); +} + +/* Toggle Switch Styling - Tiny */ +.switch { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + margin-right: 6px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: .4s; + border-radius: 22px; +} + +.slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background-color: white; + transition: .4s; + border-radius: 50%; +} + +input:checked+.slider { + background-color: #2196F3; +} + +input:checked+.slider:before { + transform: translateX(18px); +} + +/* Ultra compact popup sizing */ +.tab-content { + max-height: 550px; + overflow-y: auto; + padding: 2px; +} + +/* Market info and other sections ultra compact */ +h3, +h4 { + margin: 4px 0; + font-size: 13px; +} + +p, +div { + margin: 2px 0; + line-height: 1.2; +} + +/* Remove extra spacing from specific elements */ +.primary-buttons, +.secondary-buttons { + margin: 0; +} + +/* Compact the label preview and other right column content */ +.right-column-combined>* { + margin-bottom: 4px; +} + +/* Make dropdowns more compact */ +select { + padding: 1px; + font-size: 9px; + margin-bottom: 1px; + height: 20px; +} + +/* Compact status messages */ +.status-message { + padding: 4px; + margin: 2px 0; + font-size: 11px; +} + +/* Mapping tab styles */ +.mapping-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 4px; + margin: 8px 0; +} + +.mapping-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px; + border: 1px solid #ddd; + border-radius: 3px; + background: #f9f9f9; +} + +.mapping-item label { + font-size: 8px; + font-weight: bold; + margin-bottom: 2px; + text-align: center; + line-height: 1.1; +} + +.mapping-item .column-letter { + font-size: 10px; + color: #666; + margin-bottom: 2px; +} + +.mapping-item select { + width: 100%; + font-size: 8px; + padding: 1px; + height: 18px; + border: 1px solid #ccc; +} + +.mapping-actions { + display: flex; + gap: 8px; + margin-bottom: 8px; + justify-content: center; +} + +.mapping-actions button { + padding: 6px 12px; + margin-right: 8px; + background: #007cba; + color: white; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 12px; +} + +.mapping-actions button:hover { + background: #005a87; +} + +.sheet-info { + margin: 15px 0; + padding: 10px; + background: #f9f9f9; + border-radius: 4px; + border: 1px solid #ddd; +} + +.info-row { + display: flex; + align-items: center; + margin-bottom: 8px; +} + +.info-row:last-child { + margin-bottom: 0; +} + +.info-row label { + font-weight: bold; + min-width: 100px; + margin-right: 10px; +} + +.info-row span { + color: #666; + margin-right: 10px; +} + +.info-row input { + padding: 4px 8px; + border: 1px solid #ccc; + border-radius: 3px; + margin-right: 8px; + width: 80px; +} + +/* ================================================================ + PROMPT button in Discogs action row + ================================================================ */ +.rfid-quick-button { + background: linear-gradient(135deg, #ff6f00, #e65100); +} +.rfid-quick-button:hover { + background: linear-gradient(135deg, #e65100, #bf360c); +} + +/* Settings — compact check grid */ +.settings-section-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .5px; + color: #555; + margin-bottom: 8px; +} +.settings-check-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px 8px; +} +.settings-check-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} +.settings-check-item input[type="checkbox"] { + width: 15px; + height: 15px; + cursor: pointer; + flex-shrink: 0; +} + +/* Per-field label style grid */ +.lfs-grid { + margin-top: 8px; + display: grid; + grid-template-columns: 1fr; + gap: 2px; +} +.lfs-header, .lfs-row { + display: grid; + grid-template-columns: 1fr 18px 18px 18px; + align-items: center; + gap: 2px; +} +.lfs-header { + font-size: 10px; + font-weight: 700; + color: #888; + text-align: center; + padding-bottom: 2px; + border-bottom: 1px solid #e0e0e0; + margin-bottom: 2px; +} +.lfs-header span:first-child { text-align: left; } +.lfs-row span { + font-size: 11px; + color: #444; + white-space: nowrap; +} +.lfs-row input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + justify-self: center; +} + +/* ================================================================ + Research Tab + ================================================================ */ +.research-container { + padding: 6px 8px; +} + +.research-header h3 { + color: #1a1a2e; + font-size: 13px; + margin: 0 0 3px; + padding-bottom: 5px; + border-bottom: 2px solid #9b59b6; + letter-spacing: 0.3px; +} + +.research-desc { + color: #888; + font-size: 10px; + font-style: italic; + margin: 3px 0 8px; + line-height: 1.4; +} + +/* Release card */ +.research-release-card { + display: flex; + gap: 10px; + align-items: flex-start; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 60%, #0f3460 100%); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 8px; + box-shadow: 0 3px 12px rgba(0,0,0,0.3); +} + +.research-cover-wrap { + position: relative; + width: 58px; + height: 58px; + flex-shrink: 0; + border-radius: 5px; + overflow: hidden; + background: #0f3460; + box-shadow: 0 2px 8px rgba(0,0,0,0.5); +} + +.research-cover { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.research-cover-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + color: rgba(155,89,182,0.6); + background: linear-gradient(135deg, #0f3460, #1a1a2e); +} + +.research-release-meta { + flex: 1; + min-width: 0; +} + +.research-artist { + font-size: 9px; + color: #c084fc; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.research-title { + font-size: 13px; + font-weight: 700; + color: #fff; + margin-bottom: 3px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.research-meta-row { + font-size: 9px; + color: #94a3b8; + margin-bottom: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.research-format-row { + color: #64748b; +} + +.research-sep { + margin: 0 3px; + color: #475569; +} + +.research-tags { + display: flex; + flex-wrap: wrap; + gap: 3px; + margin-top: 4px; +} + +.research-tag { + font-size: 8px; + padding: 1px 6px; + border-radius: 10px; + font-weight: 600; + letter-spacing: 0.2px; +} + +.research-tag-genre { + background: rgba(59,130,246,0.2); + border: 1px solid rgba(59,130,246,0.4); + color: #93c5fd; +} + +.research-tag-style { + background: rgba(155,89,182,0.2); + border: 1px solid rgba(155,89,182,0.4); + color: #c084fc; +} + +/* Market stats grid */ +.research-market { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 4px; + margin-bottom: 8px; +} + +.research-stat { + background: #f8f9fa; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 5px 3px 4px; + text-align: center; +} + +.rstat-label { + display: block; + font-size: 8px; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.4px; + margin-bottom: 2px; +} + +.rstat-value { + display: block; + font-size: 11px; + font-weight: 700; + color: #1e293b; + line-height: 1.1; +} + +.rstat-value.rstat-small { + font-size: 8px; + font-weight: 600; +} + +.rstat-value.rstat-accent { + color: #e74c3c; +} + +.rstat-value.rstat-median { + color: #059669; +} + +.rstat-sub { + display: block; + font-size: 7px; + color: #94a3b8; + margin-top: 1px; +} + +/* Tracklist preview */ +.research-tracklist-wrap { + margin-bottom: 8px; + background: #f8f9fa; + border-radius: 6px; + border: 1px solid #e2e8f0; + overflow: hidden; +} + +.research-section-label { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #64748b; + padding: 4px 8px; + background: #e2e8f0; +} + +.research-tracklist { + padding: 4px 6px; +} + +.research-track { + display: flex; + align-items: baseline; + gap: 6px; + padding: 2px 0; + border-bottom: 1px solid #f1f5f9; + font-size: 9px; + line-height: 1.3; +} + +.research-track:last-child { + border-bottom: none; +} + +.research-track-pos { + color: #9b59b6; + font-weight: 700; + min-width: 14px; + flex-shrink: 0; + font-size: 8px; +} + +.research-track-title { + flex: 1; + color: #1e293b; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.research-track-extra { + color: #94a3b8; + font-size: 8px; + flex-shrink: 0; + font-style: italic; + max-width: 90px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.research-track-dur { + color: #64748b; + font-size: 8px; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +/* Export action buttons */ +.research-actions { + display: flex; + gap: 6px; + margin-bottom: 6px; +} + +.research-btn { + flex: 1; + padding: 8px 6px; + border: none; + border-radius: 6px; + font-size: 11px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0,0,0,0.15); + letter-spacing: 0.2px; +} + +.research-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0,0,0,0.2); +} + +.research-btn:active { + transform: translateY(0); + box-shadow: 0 1px 2px rgba(0,0,0,0.15); +} + +.research-btn-icon { + margin-right: 3px; + font-style: normal; +} + +.research-btn-download { + background: linear-gradient(135deg, #9b59b6, #8e44ad); + color: white; +} + +.research-btn-download:hover { + background: linear-gradient(135deg, #8e44ad, #7d3c98); +} + +.research-btn-copy { + background: linear-gradient(135deg, #1e293b, #0f172a); + color: white; +} + +.research-btn-copy:hover { + background: linear-gradient(135deg, #0f172a, #020617); +} + +.research-btn-gemini { + background: linear-gradient(135deg, #10b981, #0ea5e9); + color: white; +} + +.research-btn-gemini:hover { + background: linear-gradient(135deg, #059669, #0284c7); +} + +.research-btn-drive { + background: linear-gradient(135deg, #4285F4, #1a73e8); + color: white; + flex: 0 0 auto; + padding: 8px 12px; +} + +.research-btn-drive:hover { + background: linear-gradient(135deg, #1a73e8, #1557b0); +} + +/* Status line */ +.research-status { + font-size: 10px; + min-height: 14px; + margin-bottom: 5px; + font-weight: 600; + padding: 0 2px; +} + +.research-status.ok { color: #059669; } +.research-status.err { color: #e74c3c; } +.research-status.inf { color: #9b59b6; } + +/* Collapsible JSON preview */ +.research-preview-toggle { + width: 100%; + background: #e2e8f0; + border: none; + border-radius: 6px; + padding: 5px 8px; + font-size: 9px; + font-weight: 700; + color: #475569; + cursor: pointer; + text-align: left; + text-transform: uppercase; + letter-spacing: 0.4px; + transition: background 0.2s; + margin-bottom: 3px; +} + +.research-preview-toggle:hover { + background: #cbd5e1; +} + +.research-preview { + font-size: 8px; + font-family: 'Courier New', 'SF Mono', monospace; + padding: 8px; + margin: 0 0 4px; + white-space: pre-wrap; + word-break: break-word; + max-height: 180px; + overflow-y: auto; + color: #1e293b; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + line-height: 1.4; +} + +/* ================================================================ + End Research Tab + ================================================================ */ + +.info-row button { + padding: 4px 8px; + background: #666; + color: white; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 11px; +} diff --git a/logo.svg b/logo.svg new file mode 100644 index 0000000..e8a594d --- /dev/null +++ b/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..7c656e6 --- /dev/null +++ b/manifest.json @@ -0,0 +1,106 @@ +{ + "manifest_version": 3, + "name": "PliceCogs X YT", + "version": "10.1", + "description": "Discogs pricing tool and YT cookie blagginate crossover", + "permissions": [ + "activeTab", + "alarms", + "cookies", + "downloads", + "scripting", + "storage", + "tabs" + ], + "host_permissions": [ + "*://*.priceclogs.com/*", + "*://*.youtube.com/*", + "*://*.discogs.com/*", + "*://*.beatport.com/*", + "*://gemini.google.com/*", + "*://chat.deepseek.com/*", + "*://localhost/*", + "*://*.localhost/*", + "http://100.91.239.7:5002/*" + ], + "externally_connectable": { + "matches": [ + "*://localhost/*", + "*://*.localhost/*", + "*://*.local/*", + "*://*.test/*", + "*://*.dev/*" + ] + }, + "action": { + "default_popup": "popup.html" + }, + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; img-src 'self' https://api.qrserver.com/;" + }, + "content_scripts": [ + { + "matches": [ + "*://*.discogs.com/*" + ], + "js": [ + "content.js" + ] + }, + { + "matches": [ + "*://www.discogs.com/master/*" + ], + "js": [ + "monster_master.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "*://gemini.google.com/*" + ], + "js": [ + "gemini.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "*://chat.deepseek.com/*" + ], + "js": [ + "deepseek.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "*://localhost/*/wowplatter-admin-blagginate*", + "*://*.localhost/wp-admin/*" + ], + "js": [ + "blagginate-bridge.js" + ] + } + ], + "web_accessible_resources": [ + { + "resources": [ + "print.js", + "utils.js", + "small.css", + "large.css", + "xlarge.css", + "label.css", + "logo.svg" + ], + "matches": [ + "" + ] + } + ], + "background": { + "service_worker": "background.js" + } +} \ No newline at end of file diff --git a/monster_capture.js b/monster_capture.js new file mode 100644 index 0000000..c46b697 --- /dev/null +++ b/monster_capture.js @@ -0,0 +1,262 @@ +// Shared MONSTERWIKI capture helpers — pure functions, no DOM / popup deps. +// Loaded by popup.html ( + + + + + + + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..6bc068b --- /dev/null +++ b/popup.js @@ -0,0 +1,4930 @@ +// Create placeholder functions for missing API functions +async function getStoredDiscogsToken() { + return new Promise((resolve) => { + if (!chrome || !chrome.storage) { + resolve(null); + return; + } + chrome.storage.local.get('discogsToken', (data) => { + resolve((data.discogsToken || '').trim() || null); + }); + }); +} + +async function getReleaseData(releaseId) { + const token = await getStoredDiscogsToken(); + if (!token) return null; + const idMatch = String(releaseId || '').match(/\d+/); + const id = idMatch ? idMatch[0] : null; + if (!id) return null; + + // Retry up to 3 times — Discogs occasionally rate-limits or drops the first + // request on a freshly-loaded page, which would otherwise crash the popup. + for (let attempt = 0; attempt < 3; attempt++) { + try { + const response = await fetch(`https://api.discogs.com/releases/${id}`, { + headers: { + 'Authorization': `Discogs token=${token}`, + 'User-Agent': 'PriceClogs/1.0' + } + }); + if (response.status === 429) { + // Rate limited — back off and retry + await new Promise(r => setTimeout(r, 1500 * (attempt + 1))); + continue; + } + const data = await response.json(); + if (data && data.artists) return data; // success + // API returned an error envelope (e.g. { message: "..." }) — retry + if (attempt < 2) await new Promise(r => setTimeout(r, 1000)); + } catch (_) { + if (attempt < 2) await new Promise(r => setTimeout(r, 1000)); + } + } + return null; +} + +async function getPriceSuggestions(releaseId) { + try { + const token = await getStoredDiscogsToken(); + if (!token) { + throw new Error('Discogs token not configured'); + } + const idMatch = String(releaseId || '').match(/\d+/); + const id = idMatch ? idMatch[0] : null; + if (!id) { + throw new Error('Invalid release ID'); + } + const response = await fetch(`https://api.discogs.com/marketplace/price_suggestions/${id}`, { + headers: { + 'Authorization': `Discogs token=${token}`, + 'User-Agent': 'PriceClogs/1.0' + } + }); + return await response.json(); + } catch (error) { + // console.error('Error fetching price suggestions:', error); + return null; + } +} + +const state = { + releaseData: null, + originalReleaseData: null, + lastFetchTimestamp: 0, + sku: null, // 14-digit SKU generated at popup open (e.g. 20260313084637) + sheetTimestamp: null, // Full ISO timestamp matching the SKU, used for sheet column A + discogsTabId: null, // Tab ID of the Discogs page, captured at popup open + fetchCooldown: 1000, + collectionBoxIndex: 0, // currently selected collection box index + collectionBoxSkus: [], // SKUs for each box (empty string = no SKU) + collectionBoxCount: 1, // total number of collection boxes on page + // MONSTERWIKI background sync results (populated silently at popup open) + monsterSalesHistory: null, + monsterCurrentListings: null, + geminiAutoTriggered: false, +}; + +// ================================================================ +// MONSTERWIKI — background sync helpers +// ================================================================ + +// parseSalesHistoryHtml + parseListingsHtml moved to monster_capture.js +// (loaded before popup.js in popup.html) so background.js can share them. + +async function getMonsterSettings() { + return new Promise(resolve => + chrome.storage.local.get(['monsterSettings'], r => + resolve(r.monsterSettings || { enabled: false, preferLocalhost: false, serverUrl: 'http://100.91.239.7:5002' }) + ) + ); +} + +function monsterSetIndicator(msg, color, fadeAfterMs = 0) { + const el = document.getElementById('monster-sync-indicator'); + if (!el) return; + el.textContent = msg; + el.style.color = color; + el.style.opacity = '1'; + el.style.display = 'block'; + if (fadeAfterMs) { + setTimeout(() => { + el.style.transition = 'opacity 1.5s'; + el.style.opacity = '0'; + setTimeout(() => { el.style.display = 'none'; }, 1600); + }, fadeAfterMs); + } +} + +function geminiSetIndicator(msg, color, fadeAfterMs = 0) { + const el = document.getElementById('gemini-sync-indicator'); + if (!el) return; + el.textContent = msg; + el.style.color = color; + el.style.opacity = '1'; + el.style.display = 'block'; + if (fadeAfterMs) { + setTimeout(() => { + el.style.transition = 'opacity 1.5s'; + el.style.opacity = '0'; + setTimeout(() => { el.style.display = 'none'; }, 1600); + }, fadeAfterMs); + } +} + +async function monsterResolveBase(cfg) { + // If we're ON ultra, localhost:5002 will answer immediately — use it directly. + // Otherwise fall back to the configured Tailscale URL. + try { + const r = await fetch('http://localhost:5002/plice/health', + { signal: AbortSignal.timeout(400) }); + if (r.ok) return 'http://localhost:5002'; + } catch (_) {} + return (cfg.serverUrl || 'http://100.91.239.7:5002').replace(/\/$/, ''); +} + +async function monsterBackgroundSync(releaseId) { + const cfg = await getMonsterSettings(); + if (!cfg.enabled || !releaseId) return; + + const base = await monsterResolveBase(cfg); + monsterSetIndicator('⟳ MONSTER syncing…', '#888'); + + const [histHtml, listHtml] = await Promise.all([ + fetch(`https://www.discogs.com/sell/history/${releaseId}`) + .then(r => r.ok ? r.text() : null).catch(() => null), + fetch(`https://www.discogs.com/sell/list?release_id=${releaseId}`) + .then(r => r.ok ? r.text() : null).catch(() => null), + ]); + + state.monsterSalesHistory = histHtml ? parseSalesHistoryHtml(histHtml) : null; + state.monsterCurrentListings = listHtml ? parseListingsHtml(listHtml) : null; + + if (!state.releaseData) return; + + // Scroll to bottom of page so reviews lazy-load, wait 2s, then scrape + let liveReviews = state.releaseData.reviews || []; + if (state.discogsTabId) { + try { + const results = await chrome.scripting.executeScript({ + target: { tabId: state.discogsTabId }, + func: async () => { + window.scrollTo(0, document.body.scrollHeight); + await new Promise(r => setTimeout(r, 2000)); + const reviews = []; + document.querySelectorAll('#release-reviews [data-username], #release-reviews [class*="review_card"]') + .forEach(card => { + const username = card.getAttribute('data-username') + || card.querySelector('[class*="username"]')?.textContent?.trim() || null; + const dateEl = card.querySelector('time'); + const date = dateEl?.getAttribute('datetime')?.split('T')[0] + || dateEl?.textContent?.trim() || null; + const text = card.querySelector('[class*="review_body"], [class*="reviewBody"]') + ?.textContent?.trim() || null; + const rating = card.querySelectorAll('[class*="star_full"],[class*="starFull"],[class*="filled"]').length || null; + const helpful = parseInt(card.querySelector('[class*="helpful"]')?.textContent?.match(/\d+/)?.[0]) || 0; + if (username && (text || rating)) + reviews.push({ username, date, rating: rating||null, text, helpful, replies: [] }); + }); + return reviews; + } + }); + if (results?.[0]?.result?.length) liveReviews = results[0].result; + } catch (_) {} + } + + // Merge live DOM reviews into releaseData for the POST + const postData = { ...state.releaseData, reviews: liveReviews }; + + try { + const resp = await fetch(`${base}/plice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...buildResearchJson(postData), + sales_history: state.monsterSalesHistory, + current_listings: state.monsterCurrentListings, + }), + signal: AbortSignal.timeout(12000), + }); + if (resp.ok) { + const d = await resp.json(); + const sales = d.sale_rows || 0; + const listings = d.listing_rows || 0; + const reviews = d.review_rows || 0; + monsterSetIndicator( + `✓ MONSTER — ${sales} sales · ${listings} listings · ${reviews} reviews`, + '#2a7a2a', 4000 + ); + } else { + monsterSetIndicator(`✗ MONSTER ${resp.status}`, '#b00', 4000); + } + } catch (e) { + monsterSetIndicator( + e.name === 'TimeoutError' ? '✗ MONSTER timeout' : '✗ MONSTER unreachable', + '#b00', 4000 + ); + } +} + +// YouTube Cookie Management Functions +function extractCookies() { + const button = document.getElementById('getCookies'); + const textarea = document.getElementById('cookieOutput'); + + if (!button || !textarea) return; // Elements don't exist on this page + + // Check if chrome runtime is available + if (typeof chrome === 'undefined' || !chrome.runtime) { + textarea.value = 'Extension functionality not available in preview mode.\nInstall as Chrome extension to use YouTube cookie extraction.'; + return; + } + + // Show loading state + button.disabled = true; + button.textContent = 'Extracting...'; + textarea.value = 'Extracting cookies...'; + + chrome.runtime.sendMessage({ action: "getCookies" }, async function (response) { + if (response && response.success && response.cookieText) { + textarea.value = response.cookieText; + + // Try to copy to clipboard + let copied = false; + try { + await navigator.clipboard.writeText(response.cookieText); + copied = true; + } catch (err) { + // console.error('Failed to copy:', err); + } + + button.textContent = copied ? 'Cookies Saved & Copied!' : 'Cookies Saved!'; + button.style.backgroundColor = '#45a049'; + + // Reset button after delay + setTimeout(() => { + button.textContent = 'Extract Cookies'; + button.style.backgroundColor = '#4CAF50'; + button.disabled = false; + }, 2000); + } else { + // Handle error + console.error('Error response:', response); + textarea.value = response?.error || 'Failed to extract cookies'; + button.textContent = 'Error - Try Again'; + button.style.backgroundColor = '#f44336'; + button.disabled = false; + + setTimeout(() => { + button.textContent = 'Extract Cookies'; + button.style.backgroundColor = '#4CAF50'; + }, 3000); + } + }); +} + +function initializeYouTubeCookieUI() { + const toggle = document.getElementById('autoUpdate'); + const blagginateToggle = document.getElementById('blagginateBridge'); + const textarea = document.getElementById('cookieOutput'); + const button = document.getElementById('getCookies'); + const container = document.querySelector('.youtube-cookie-container'); + + if (!toggle || !blagginateToggle || !textarea || !button || !container) return; // Elements don't exist + + // Check if chrome.storage is available (extension context) + if (typeof chrome !== 'undefined' && chrome.storage) { + // Load initial state + chrome.storage.local.get(['autoUpdate', 'blagginateBridge', 'lastCookie', 'lastUpdate'], function (data) { + // Set toggle states + toggle.checked = data.autoUpdate === true; + blagginateToggle.checked = data.blagginateBridge !== false; // Default to true + + // Show last saved cookie if available + if (data.lastCookie) { + const lastUpdateTime = new Date(data.lastUpdate).toLocaleString(); + textarea.value = `Last saved on ${lastUpdateTime}:\n\n${data.lastCookie}`; + } else { + textarea.value = 'No cookies saved yet. Click "Extract Cookies" to get YouTube cookies.'; + } + + // If auto-update is enabled, get fresh cookies + if (data.autoUpdate === true) { + extractCookies(); + } + }); + + // Handle auto-update toggle changes + toggle.addEventListener('change', function (e) { + const isEnabled = e.target.checked; + chrome.storage.local.set({ autoUpdate: isEnabled }, function () { + if (isEnabled) { + extractCookies(); + } + }); + }); + + // Handle BLAGGINATE bridge toggle changes + blagginateToggle.addEventListener('change', function (e) { + const isEnabled = e.target.checked; + chrome.storage.local.set({ blagginateBridge: isEnabled }, function () { + // Send message to background script to enable/disable bridge + chrome.runtime.sendMessage({ + type: 'toggle_blagginate_bridge', + enabled: isEnabled + }); + + console.log('BLAGGINATE bridge', isEnabled ? 'enabled' : 'disabled'); + }); + }); + } else { + // Fallback for non-extension context (preview mode) + textarea.value = 'Extension functionality not available in preview mode.\nInstall as Chrome extension to use YouTube cookie extraction.'; + toggle.disabled = true; + blagginateToggle.disabled = true; + button.disabled = true; + } + + // Handle button click + button.addEventListener('click', extractCookies); + + // JDownloader 2 button: pull fresh YT cookies as Cookie-Editor JSON and copy to clipboard. + const jdBtn = document.getElementById('getCookiesJd2'); + if (jdBtn) { + jdBtn.addEventListener('click', () => { + const orig = jdBtn.textContent; + jdBtn.disabled = true; + jdBtn.textContent = 'Fetching...'; + chrome.runtime.sendMessage({ action: 'getCookiesJson' }, async (response) => { + if (response && response.success && response.cookieJson) { + try { + await navigator.clipboard.writeText(response.cookieJson); + textarea.value = response.cookieJson; + jdBtn.textContent = 'Copied! Paste into JD2 password field'; + jdBtn.style.background = '#45a049'; + } catch (err) { + textarea.value = response.cookieJson; + jdBtn.textContent = 'Shown below — copy manually'; + jdBtn.style.background = '#f39c12'; + } + } else { + jdBtn.textContent = 'Failed: ' + ((response && response.error) || 'unknown'); + jdBtn.style.background = '#f44336'; + } + setTimeout(() => { + jdBtn.textContent = orig; + jdBtn.style.background = '#2196F3'; + jdBtn.disabled = false; + }, 2500); + }); + }); + } +} + +// Tab switching functionality +function initializeTabs() { + const tabButtons = document.querySelectorAll('.tab-button'); + const tabContents = document.querySelectorAll('.tab-content'); + + tabButtons.forEach(button => { + button.addEventListener('click', () => { + const targetTab = button.getAttribute('data-tab'); + + // Remove active class from all buttons and contents + tabButtons.forEach(btn => btn.classList.remove('active')); + tabContents.forEach(content => content.classList.remove('active')); + + // Add active class to clicked button and corresponding content + button.classList.add('active'); + document.getElementById(`${targetTab}-tab`).classList.add('active'); + }); + }); +} + +// Save functions - defined before DOMContentLoaded to ensure availability +function saveDiscogsToken() { + if (!chrome || !chrome.storage) { + alert('Chrome storage not available in preview mode'); + return; + } + + const token = document.getElementById('discogs-token').value.trim(); + console.log('Saving Discogs token:', { hasToken: !!token }); + + if (token) { + chrome.storage.local.set({ discogsToken: token }, () => { + console.log('Discogs token saved successfully to chrome.storage.local'); + if (chrome.runtime.lastError) { + console.error('Error saving Discogs token:', chrome.runtime.lastError); + alert('Error saving Discogs token: ' + chrome.runtime.lastError.message); + } + }); + } else { + console.log('No Discogs token to save (field empty)'); + } +} + +function saveGoogleSettings() { + if (!chrome || !chrome.storage) { + alert('Chrome storage not available in preview mode'); + return; + } + + const spreadsheetId = document.getElementById('spreadsheet-id').value; + const serviceAccountJson = document.getElementById('google-service-account-json').value; + + console.log('Saving Google settings:', { spreadsheetId, hasServiceAccount: !!serviceAccountJson }); + + let googleSettings = {}; + + if (spreadsheetId) { + googleSettings.spreadsheetId = spreadsheetId; + } + + if (serviceAccountJson) { + try { + const serviceAccount = JSON.parse(serviceAccountJson); + if (serviceAccount.client_email && serviceAccount.private_key) { + googleSettings.clientEmail = serviceAccount.client_email; + googleSettings.privateKey = serviceAccount.private_key; + } + } catch (error) { + console.error('Invalid JSON in service account field:', error); + alert('Invalid JSON format in Service Account JSON field'); + return; + } + } + + if (Object.keys(googleSettings).length > 0) { + chrome.storage.local.set({ googleSettings: googleSettings }, () => { + console.log('Google settings saved successfully to chrome.storage.local'); + if (chrome.runtime.lastError) { + console.error('Error saving Google settings:', chrome.runtime.lastError); + alert('Error saving Google settings: ' + chrome.runtime.lastError.message); + } + }); + } else { + console.log('No Google settings to save (all fields empty)'); + } +} + +document.addEventListener('DOMContentLoaded', function () { + // Initialize tab functionality first + initializeTabs(); + + // Initialize mapping tab + initializeMappingTab(); + + // Initialize YouTube cookie functionality + initializeYouTubeCookieUI(); + + // Load auto-update Discogs price setting + const autoUpdatePriceCheckbox = document.getElementById('autoUpdateDiscogsPrice'); + if (autoUpdatePriceCheckbox && chrome && chrome.storage) { + chrome.storage.local.get(['autoUpdateDiscogsPrice'], (result) => { + // Default to true if not set + autoUpdatePriceCheckbox.checked = result.autoUpdateDiscogsPrice !== false; + }); + + // Save setting when changed + autoUpdatePriceCheckbox.addEventListener('change', () => { + chrome.storage.local.set({ autoUpdateDiscogsPrice: autoUpdatePriceCheckbox.checked }); + console.log('Auto-update Discogs price setting saved:', autoUpdatePriceCheckbox.checked); + }); + } + + // Load and persist QR SKU toggle; show indicator on Discogs tab + const qrUseSkuCheckbox = document.getElementById('qr-use-sku'); + const skuIndicator = document.getElementById('sku-mode-indicator'); + const skuIndicatorStatus = document.getElementById('sku-mode-status'); + function updateSkuIndicator(on) { + if (!skuIndicator) return; + skuIndicator.style.display = 'block'; + skuIndicatorStatus.textContent = on ? 'ON' : 'OFF'; + skuIndicatorStatus.style.color = on ? '#28a745' : '#dc3545'; + } + if (qrUseSkuCheckbox && chrome && chrome.storage) { + chrome.storage.local.get(['qrUseSku'], (result) => { + qrUseSkuCheckbox.checked = result.qrUseSku === true; + updateSkuIndicator(result.qrUseSku === true); + }); + qrUseSkuCheckbox.addEventListener('change', () => { + chrome.storage.local.set({ qrUseSku: qrUseSkuCheckbox.checked }); + updateSkuIndicator(qrUseSkuCheckbox.checked); + }); + } + + // Load enable JSON download setting + const enableJsonDownloadCheckbox = document.getElementById('enableJsonDownload'); + if (enableJsonDownloadCheckbox && chrome && chrome.storage) { + chrome.storage.local.get(['enableJsonDownload'], (result) => { + // Default to true if not set + enableJsonDownloadCheckbox.checked = result.enableJsonDownload !== false; + }); + + // Save setting when changed + enableJsonDownloadCheckbox.addEventListener('change', () => { + chrome.storage.local.set({ enableJsonDownload: enableJsonDownloadCheckbox.checked }); + console.log('Enable JSON download setting saved:', enableJsonDownloadCheckbox.checked); + }); + } + + // ── Settings: ALL config + RFID + visibility ───────────────────────────── + const settingsConfig = [ + // [storageKey, elementId, defaultValue] + ['allDoesLabel', 'allDoesLabel', true], + ['allDoesSheet', 'allDoesSheet', true], + ['allDoesRfid', 'allDoesRfid', true], + ['allDoesJson', 'allDoesJson', true], + ['allDoesPrice', 'allDoesPrice', true], + ['qrUseSku', 'qrUseSku', false], + ['rfidIncludeReleaseId', 'rfidIncludeReleaseId', false], + ['rfidAppendR', 'rfidAppendR', false], + ['btnShowLabel', 'btnShowLabel', true], + ['btnShowRfid', 'btnShowRfid', true], + ['btnShowSheet', 'btnShowSheet', true], + ['btnShowJson', 'btnShowJson', true], + ['btnShowInventory', 'btnShowInventory', true], + ['btnShowLabel203', 'btnShowLabel203', false], + ['enableJsonDownload', 'enableJsonDownload', true], + ['stripSkuSuffix', 'stripSkuSuffix', false], + ]; + const allKeys = settingsConfig.map(c => c[0]); + chrome.storage.local.get(allKeys, (stored) => { + for (const [key, id, def] of settingsConfig) { + const el = document.getElementById(id); + if (!el) continue; + el.checked = stored[key] !== undefined ? stored[key] : def; + el.addEventListener('change', () => { + chrome.storage.local.set({ [key]: el.checked }); + if (id.startsWith('btnShow')) applyButtonVisibility(stored); + if (id === 'stripSkuSuffix') { + window.stripSkuSuffix = el.checked; + if (state.releaseData) updatePreview(state.releaseData); + } + }); + } + window.stripSkuSuffix = stored.stripSkuSuffix === true; + applyButtonVisibility(stored); + }); + + function applyButtonVisibility(stored) { + // Re-read live from checkboxes (they may have just changed) + const showLabel = document.getElementById('btnShowLabel')?.checked ?? true; + const showRfid = document.getElementById('btnShowRfid')?.checked ?? true; + const showSheet = document.getElementById('btnShowSheet')?.checked ?? true; + const showJson = document.getElementById('btnShowJson')?.checked ?? true; + const showInventory = document.getElementById('btnShowInventory')?.checked ?? true; + const showLabel203 = document.getElementById('btnShowLabel203')?.checked ?? false; + + const setVis = (id, show) => { + const el = document.getElementById(id); + if (el) el.style.display = show ? '' : 'none'; + }; + setVis('printLabel', showLabel); + setVis('printLabel203', showLabel203); + setVis('rfidQuickWrite', showRfid); + setVis('updateSheet', showSheet); + setVis('writeJSON', showJson); + setVis('importWordPress', showInventory); + + // Hide small row entirely if all buttons hidden + const smallRow = document.getElementById('buttons-small-row'); + if (smallRow) smallRow.style.display = (showSheet || showJson || showInventory) ? '' : 'none'; + } + + function applyLabelPreviewVisibility() { + const size = document.getElementById('labelSize')?.value || 'large'; + const previewSmall = document.querySelector('.label-preview'); + const previewLarge = document.querySelector('.label-preview-large'); + const previewXLarge = document.querySelector('.label-preview-xlarge'); + if (previewSmall) previewSmall.style.display = size === 'small' ? '' : 'none'; + if (previewLarge) previewLarge.style.display = size === 'large' ? '' : 'none'; + if (previewXLarge) previewXLarge.style.display = size === 'xlarge' ? '' : 'none'; + } + + // ── Label settings (font/bold/italic/size + per-field styles) ──────────── + const LFS_FIELDS = ['artist','title','genre','style','format','info','price','condition','comment','notes']; + + function applyLabelFieldStyles(lfs) { + window.labelFieldStyles = lfs || {}; + LFS_FIELDS.forEach(f => { + const s = (lfs || {})[f] || {}; + const bEl = document.getElementById(`lfs-${f}-b`); + const iEl = document.getElementById(`lfs-${f}-i`); + const eEl = document.getElementById(`lfs-${f}-e`); + if (bEl) bEl.checked = !!s.b; + if (iEl) iEl.checked = !!s.i; + if (eEl) eEl.checked = !!s.e; + }); + } + + function readLabelFieldStyles() { + const lfs = {}; + LFS_FIELDS.forEach(f => { + lfs[f] = { + b: !!(document.getElementById(`lfs-${f}-b`)?.checked), + i: !!(document.getElementById(`lfs-${f}-i`)?.checked), + e: !!(document.getElementById(`lfs-${f}-e`)?.checked), + }; + }); + return lfs; + } + + chrome.storage.local.get(['labelFont', 'labelBold', 'labelItalic', 'labelSize', 'labelFieldStyles'], (ls) => { + const fontSel = document.getElementById('label-font-select'); + const boldCb = document.getElementById('label-bold-toggle'); + const italicCb = document.getElementById('label-italic-toggle'); + const sizeSel = document.getElementById('labelSize'); + if (fontSel && ls.labelFont) fontSel.value = ls.labelFont; + if (boldCb) boldCb.checked = ls.labelBold === true; + if (italicCb) italicCb.checked = ls.labelItalic === true; + if (sizeSel && ls.labelSize) sizeSel.value = ls.labelSize; + applyLabelPreviewVisibility(); + applyLabelFieldStyles(ls.labelFieldStyles); + + const onLabelChange = () => { + const lfs = readLabelFieldStyles(); + window.labelFieldStyles = lfs; + chrome.storage.local.set({ + labelFont: fontSel?.value, + labelBold: boldCb?.checked, + labelItalic: italicCb?.checked, + labelSize: sizeSel?.value, + labelFieldStyles: lfs, + }); + if (state.releaseData) updatePreview(state.releaseData); + }; + fontSel?.addEventListener('change', onLabelChange); + boldCb?.addEventListener('change', onLabelChange); + italicCb?.addEventListener('change', onLabelChange); + sizeSel?.addEventListener('change', onLabelChange); + + // Wire per-field checkboxes + LFS_FIELDS.forEach(f => { + ['b','i','e'].forEach(prop => { + document.getElementById(`lfs-${f}-${prop}`)?.addEventListener('change', onLabelChange); + }); + }); + }); + + // Check if chrome.tabs is available (extension context) + if (typeof chrome !== 'undefined' && chrome.tabs) { + chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) { + if (!tabs || !tabs[0] || !tabs[0].url) { + console.error('No active tab or URL found'); + return; + } + + const url = tabs[0].url; + const releaseMatch = url.match(/\/release\/(\d+)/); + const masterMatch = url.match(/\/master\/(\d+)/); + const styleMatch = url.match(/\/style\/([^\/]+)/); + const isYouTube = url.includes('youtube.com'); + + // Determine which tab to show based on the current page + if (isYouTube) { + document.querySelector('.tab-button[data-tab="youtube"]').click(); + } else if (releaseMatch || styleMatch || masterMatch) { + document.querySelector('.tab-button[data-tab="discogs"]').click(); + } else { + document.querySelector('.tab-button[data-tab="settings"]').click(); + } + + if (styleMatch) { + document.getElementById('stylePageContainer').style.display = 'block'; + } else if (releaseMatch) { + fetchAndDisplayReleaseData(releaseMatch[1]); + } else if (masterMatch) { + showMasterPageStatus(masterMatch[1], tabs[0].id); + } + }); + + loadWpSettings(); + loadGoogleSettings(); + loadInventorySettings(); + loadDiscogsToken(); + (function () { var el = document.getElementById('extension-id-display'); if (el) { if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.id) { el.textContent = chrome.runtime.id; } } })(); + // Copy Extension ID button + (function () { + var btn = document.getElementById('copy-extension-id'); + if (btn) { + btn.addEventListener('click', async function () { + var id = (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.id) ? chrome.runtime.id : (document.getElementById('extension-id-display').textContent || '').trim(); + try { + await navigator.clipboard.writeText(id); + var status = document.getElementById('copy-extension-id-status'); + if (status) { + status.textContent = 'Copied!'; + status.style.display = 'inline'; + setTimeout(function () { status.style.display = 'none'; }, 1500); + } + } catch (e) { + try { + var temp = document.createElement('textarea'); + temp.value = id; + document.body.appendChild(temp); + temp.select(); + document.execCommand('copy'); + document.body.removeChild(temp); + var status2 = document.getElementById('copy-extension-id-status'); + if (status2) { + status2.textContent = 'Copied!'; + status2.style.display = 'inline'; + setTimeout(function () { status2.style.display = 'none'; }, 1500); + } + } catch (err2) { + console.error('Clipboard copy failed:', err2); + alert('Could not copy extension ID. Please copy manually.'); + } + } + }); + } + })(); + } + + // Add all event listeners inside DOMContentLoaded to ensure DOM elements exist + document.getElementById('settings-toggle-connect').addEventListener('click', () => { + document.getElementById('settings-content-connect').style.display = document.getElementById('settings-content-connect').style.display === 'none' ? 'block' : 'none'; + }); + + document.getElementById('save-connect-settings').addEventListener('click', () => { + console.log('Save Connect Settings button clicked!'); + + // Save Google Settings + if (!chrome || !chrome.storage) { + alert('Chrome storage not available in preview mode'); + return; + } + + const spreadsheetId = document.getElementById('spreadsheet-id').value; + const serviceAccountJson = document.getElementById('google-service-account-json').value; + + console.log('Saving Google settings:', { spreadsheetId, hasServiceAccount: !!serviceAccountJson }); + + let googleSettings = {}; + + if (spreadsheetId) { + googleSettings.spreadsheetId = spreadsheetId; + } + + if (serviceAccountJson) { + try { + const serviceAccount = JSON.parse(serviceAccountJson); + if (serviceAccount.client_email && serviceAccount.private_key) { + googleSettings.clientEmail = serviceAccount.client_email; + googleSettings.privateKey = serviceAccount.private_key; + } + } catch (error) { + console.error('Invalid JSON in service account field:', error); + alert('Invalid JSON format in Service Account JSON field'); + return; + } + } + + if (Object.keys(googleSettings).length > 0) { + chrome.storage.local.set({ googleSettings: googleSettings }, () => { + console.log('Google settings saved successfully to chrome.storage.local'); + if (chrome.runtime.lastError) { + console.error('Error saving Google settings:', chrome.runtime.lastError); + alert('Error saving Google settings: ' + chrome.runtime.lastError.message); + } else { + alert('Google settings saved successfully!'); + } + }); + } else { + console.log('No Google settings to save (all fields empty)'); + } + + // Save Discogs Token + const discogsToken = document.getElementById('discogs-token').value.trim(); + if (discogsToken) { + chrome.storage.local.set({ discogsToken: discogsToken }, () => { + console.log('Discogs token saved successfully to chrome.storage.local'); + if (chrome.runtime.lastError) { + console.error('Error saving Discogs token:', chrome.runtime.lastError); + alert('Error saving Discogs token: ' + chrome.runtime.lastError.message); + } + }); + } else { + console.log('No Discogs token to save (field empty)'); + } + + // Save Inventory SKU Lookup settings + const inventorySettings = { + enabled: document.getElementById('inventorySkuEnabled').checked, + dbHost: document.getElementById('inventory-db-host').value.trim() || '100.123.123.64', + dbName: document.getElementById('inventory-db-name').value.trim() || 'wp_rmp_disc_', + dbUser: document.getElementById('inventory-db-user').value.trim(), + dbPass: document.getElementById('inventory-db-pass').value.trim(), + sshKeyPath: document.getElementById('inventory-ssh-key').value.trim() || '~/.ssh/id_rsa' + }; + chrome.storage.local.set({ inventorySettings }, () => { + if (chrome.runtime.lastError) { + console.error('Error saving inventory settings:', chrome.runtime.lastError); + alert('Error saving inventory settings: ' + chrome.runtime.lastError.message); + } else { + console.log('Inventory settings saved successfully.'); + } + }); + }); + + // MONSTERWIKI settings handlers + document.getElementById('settings-toggle-monster').addEventListener('click', () => { + const el = document.getElementById('settings-content-monster'); + el.style.display = el.style.display === 'none' ? 'block' : 'none'; + }); + + document.getElementById('save-monster-settings').addEventListener('click', () => { + const enabled = document.getElementById('monster-enabled').checked; + const autoCapture = document.getElementById('monster-autocapture')?.checked === true; + const preferLocalhost = document.getElementById('monster-prefer-localhost')?.checked === true; + const serverUrl = document.getElementById('monster-server-url').value.trim() + || 'http://100.91.239.7:5002'; + chrome.storage.local.set({ monsterSettings: { enabled, autoCapture, preferLocalhost, serverUrl } }, () => { + document.getElementById('monster-status').textContent = 'Saved.'; + setTimeout(() => { document.getElementById('monster-status').textContent = ''; }, 2000); + }); + }); + + document.getElementById('test-monster-connection').addEventListener('click', async () => { + const statusEl = document.getElementById('monster-status'); + statusEl.textContent = 'Testing…'; + const cfg = await getMonsterSettings(); + const base = (cfg.serverUrl || 'http://100.91.239.7:5002').replace(/\/$/, ''); + try { + const r = await fetch(`${base}/plice/health`, { signal: AbortSignal.timeout(5000) }); + statusEl.textContent = r.ok ? '✓ Connected' : `✗ HTTP ${r.status}`; + } catch (e) { + statusEl.textContent = `✗ ${e.message.includes('abort') ? 'Timeout' : 'Unreachable'}`; + } + }); + + // Load saved MONSTERWIKI settings into fields + chrome.storage.local.get(['monsterSettings'], r => { + const cfg = r.monsterSettings || {}; + if (document.getElementById('monster-enabled')) + document.getElementById('monster-enabled').checked = cfg.enabled === true; + if (document.getElementById('monster-autocapture')) + document.getElementById('monster-autocapture').checked = cfg.autoCapture === true; + if (document.getElementById('monster-prefer-localhost')) + document.getElementById('monster-prefer-localhost').checked = cfg.preferLocalhost === true; + if (document.getElementById('monster-server-url')) + document.getElementById('monster-server-url').value = cfg.serverUrl || 'http://100.91.239.7:5002'; + }); + + const geminiToggle = document.getElementById('settings-toggle-gemini'); + if (geminiToggle) { + geminiToggle.addEventListener('click', () => { + const el = document.getElementById('settings-content-gemini'); + el.style.display = el.style.display === 'none' ? 'block' : 'none'; + }); + } + + const geminiTemplateEl = document.getElementById('gemini-prompt-template'); + const geminiStatusEl = document.getElementById('gemini-prompt-status'); + const geminiCloseTabEl = document.getElementById('gemini-close-tab'); + const geminiAutoRunEl = document.getElementById('gemini-auto-run'); + const aiProviderEl = document.getElementById('ai-provider'); + + chrome.storage.local.get(['geminiPromptTemplate', 'geminiCloseTabOnSuccess', 'geminiAutoRunOnPopup', 'aiProvider'], r => { + if (!geminiTemplateEl) return; + geminiTemplateEl.value = (r.geminiPromptTemplate && typeof r.geminiPromptTemplate === 'string') + ? r.geminiPromptTemplate + : defaultGeminiPromptTemplate(); + if (geminiCloseTabEl) geminiCloseTabEl.checked = r.geminiCloseTabOnSuccess !== false; + if (geminiAutoRunEl) geminiAutoRunEl.checked = r.geminiAutoRunOnPopup === true; + if (aiProviderEl) aiProviderEl.value = (r.aiProvider === 'deepseek') ? 'deepseek' : 'gemini'; + }); + + if (geminiCloseTabEl) { + geminiCloseTabEl.addEventListener('change', () => { + chrome.storage.local.set({ geminiCloseTabOnSuccess: geminiCloseTabEl.checked === true }); + }); + } + + if (geminiAutoRunEl) { + geminiAutoRunEl.addEventListener('change', () => { + chrome.storage.local.set({ geminiAutoRunOnPopup: geminiAutoRunEl.checked === true }, () => { + if (geminiStatusEl) geminiStatusEl.textContent = geminiAutoRunEl.checked ? 'Auto-run enabled.' : 'Auto-run disabled.'; + setTimeout(() => { if (geminiStatusEl) geminiStatusEl.textContent = ''; }, 1500); + }); + }); + } + + if (aiProviderEl) { + aiProviderEl.addEventListener('change', () => { + const v = aiProviderEl.value === 'deepseek' ? 'deepseek' : 'gemini'; + chrome.storage.local.set({ aiProvider: v }, () => { + if (geminiStatusEl) geminiStatusEl.textContent = `Provider: ${v}`; + setTimeout(() => { if (geminiStatusEl) geminiStatusEl.textContent = ''; }, 1500); + }); + }); + } + + const saveGeminiBtn = document.getElementById('save-gemini-prompt'); + if (saveGeminiBtn) { + saveGeminiBtn.addEventListener('click', () => { + if (!geminiTemplateEl) return; + const tpl = geminiTemplateEl.value || ''; + const closeOnSuccess = geminiCloseTabEl ? geminiCloseTabEl.checked === true : true; + const autoRun = geminiAutoRunEl ? geminiAutoRunEl.checked === true : false; + chrome.storage.local.set({ geminiPromptTemplate: tpl, geminiCloseTabOnSuccess: closeOnSuccess, geminiAutoRunOnPopup: autoRun }, () => { + if (geminiStatusEl) geminiStatusEl.textContent = 'Saved.'; + setTimeout(() => { if (geminiStatusEl) geminiStatusEl.textContent = ''; }, 2000); + }); + }); + } + + const resetGeminiBtn = document.getElementById('reset-gemini-prompt'); + if (resetGeminiBtn) { + resetGeminiBtn.addEventListener('click', () => { + if (!geminiTemplateEl) return; + geminiTemplateEl.value = defaultGeminiPromptTemplate(); + const closeOnSuccess = geminiCloseTabEl ? geminiCloseTabEl.checked === true : true; + const autoRun = geminiAutoRunEl ? geminiAutoRunEl.checked === true : false; + chrome.storage.local.set({ geminiPromptTemplate: geminiTemplateEl.value, geminiCloseTabOnSuccess: closeOnSuccess, geminiAutoRunOnPopup: autoRun }, () => { + if (geminiStatusEl) geminiStatusEl.textContent = 'Reset.'; + setTimeout(() => { if (geminiStatusEl) geminiStatusEl.textContent = ''; }, 2000); + }); + }); + } + + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local') return; + if (!changes.geminiJob) return; + const job = changes.geminiJob.newValue; + if (!job) return; + if (state.releaseData?.id && job.release_id && job.release_id !== state.releaseData.id) return; + const prov = job.provider === 'deepseek' ? 'DeepSeek' : 'Gemini'; + + if (job.status === 'posting') { + geminiSetIndicator(`${prov} saving…`, '#888'); + } else if (job.status === 'done') { + geminiSetIndicator(`✓ ${prov} done`, '#28a745', 2500); + } else if (job.status === 'error') { + geminiSetIndicator(`✗ ${prov}: ${job.error || 'error'}`, '#dc3545'); + } + }); + + document.getElementById('settings-toggle-wowplatter').addEventListener('click', () => { + document.getElementById('settings-content-wowplatter').style.display = document.getElementById('settings-content-wowplatter').style.display === 'none' ? 'block' : 'none'; + }); + + document.getElementById('save-wp-settings').addEventListener('click', () => { + saveWpSettings(); + // Note: saveWpSettings() already shows an alert, so we don't need another one + }); + + document.getElementById('test-wp-connection').addEventListener('click', testWpConnection); + + // Keep the old save-google-settings for backward compatibility (if needed) + document.getElementById('save-google-settings')?.addEventListener('click', () => { + if (!chrome || !chrome.storage) { + alert('Chrome storage not available in preview mode'); + return; + } + + const spreadsheetId = document.getElementById('spreadsheet-id').value; + const jsonText = document.getElementById('google-service-account-json').value; + try { + const serviceAccount = JSON.parse(jsonText); + chrome.storage.local.set({ + googleSettings: { + spreadsheetId, + clientEmail: serviceAccount.client_email, + privateKey: serviceAccount.private_key + } + }, () => alert('Google settings saved!')); + } catch (error) { + alert(`Error parsing service account JSON: ${error.message}`); + } + }); + + // Mapping tab event listeners + document.getElementById('save-mappings')?.addEventListener('click', saveMappings); + document.getElementById('load-mappings')?.addEventListener('click', loadMappings); + document.getElementById('reset-mappings')?.addEventListener('click', resetMappings); + document.getElementById('detect-sheet')?.addEventListener('click', detectSheetStructureUI); + document.getElementById('auto-map-headers')?.addEventListener('click', autoMapHeaders); + document.getElementById('toggle-manual-range')?.addEventListener('click', toggleManualRange); +}); + +function loadGoogleSettings() { + if (!chrome || !chrome.storage) { + console.log('Chrome storage not available in preview mode'); + return; + } + + chrome.storage.local.get('googleSettings', (data) => { + console.log('Loading Google settings:', data.googleSettings); + if (data.googleSettings) { + document.getElementById('spreadsheet-id').value = data.googleSettings.spreadsheetId || ''; + const serviceAccount = { + client_email: data.googleSettings.clientEmail, + private_key: data.googleSettings.privateKey + }; + if (data.googleSettings.clientEmail && data.googleSettings.privateKey) { + document.getElementById('google-service-account-json').value = JSON.stringify(serviceAccount, null, 2); + } + console.log('Google settings loaded successfully'); + } else { + console.log('No Google settings found in storage'); + } + }); +} + +function loadDiscogsToken() { + if (!chrome || !chrome.storage) { + console.log('Chrome storage not available in preview mode'); + return; + } + + chrome.storage.local.get('discogsToken', (data) => { + console.log('Loading Discogs token:', { hasToken: !!data.discogsToken }); + if (data.discogsToken) { + document.getElementById('discogs-token').value = (data.discogsToken || '').trim(); + console.log('Discogs token loaded successfully'); + } else { + console.log('No Discogs token found in storage'); + } + }); +} + +function loadInventorySettings() { + if (!chrome || !chrome.storage) { + console.log('Chrome storage not available in preview mode'); + return; + } + chrome.storage.local.get(['inventorySettings'], (result) => { + const s = result.inventorySettings || {}; + const el = (id) => document.getElementById(id); + if (el('inventorySkuEnabled')) el('inventorySkuEnabled').checked = !!s.enabled; + if (el('inventory-db-host')) el('inventory-db-host').value = s.dbHost || '100.123.123.64'; + if (el('inventory-db-name')) el('inventory-db-name').value = s.dbName || 'wp_rmp_disc_'; + if (el('inventory-db-user')) el('inventory-db-user').value = s.dbUser || ''; + if (el('inventory-db-pass')) el('inventory-db-pass').value = s.dbPass || ''; + if (el('inventory-ssh-key')) el('inventory-ssh-key').value = s.sshKeyPath || '~/.ssh/id_rsa'; + console.log('Inventory settings loaded successfully'); + }); +} + +function makeEditable(field) { + const valueElement = field.querySelector('.value'); + if (valueElement && valueElement.getAttribute('contenteditable') === 'true') { + valueElement.addEventListener('input', () => { + const fieldName = field.dataset.field; + if (state.releaseData) { + state.releaseData[fieldName] = valueElement.textContent; + updatePreview(state.releaseData); + } + }); + } +} + +function setupEventListeners() { + const elements = { + priceInput: document.getElementById('price'), + priceCheckboxes: document.querySelectorAll('.price-checkbox'), + sleeveCondition: document.getElementById('sleeveCondition'), + mediaCondition: document.getElementById('mediaCondition'), + commentInput: document.getElementById('commentInput'), + labelSize: document.getElementById('labelSize'), + }; + + const updateState = (key, value) => { + if (state.releaseData) { + state.releaseData[key] = value; + updatePreview(state.releaseData); + } + }; + + elements.priceInput.addEventListener('input', () => { + document.querySelectorAll('.price-checkbox').forEach(cb => cb.checked = false); + updateState('price', elements.priceInput.value); + }); + + // Also update on focus/blur/keyup/paste to ensure we catch all changes + elements.priceInput.addEventListener('focus', () => { + updateState('price', elements.priceInput.value); + }); + + elements.priceInput.addEventListener('blur', () => { + updateState('price', elements.priceInput.value); + }); + + elements.priceInput.addEventListener('keyup', () => { + updateState('price', elements.priceInput.value); + }); + + elements.priceInput.addEventListener('paste', () => { + setTimeout(() => updateState('price', elements.priceInput.value), 10); + }); + + elements.priceCheckboxes.forEach(checkbox => { + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + elements.priceCheckboxes.forEach(cb => { if (cb !== checkbox) cb.checked = false; }); + elements.priceInput.value = checkbox.value; + updateState('price', checkbox.value); + } + }); + }); + + elements.mediaCondition.addEventListener('change', () => updateState('mediaCondition', elements.mediaCondition.value)); + elements.sleeveCondition.addEventListener('change', () => updateState('sleeveCondition', elements.sleeveCondition.value)); + // Update comment on input, focus, blur, keyup and paste to ensure we catch all changes + elements.commentInput.addEventListener('input', () => updateState('comment', elements.commentInput.textContent)); + elements.commentInput.addEventListener('focus', () => updateState('comment', elements.commentInput.textContent)); + elements.commentInput.addEventListener('blur', () => updateState('comment', elements.commentInput.textContent)); + elements.commentInput.addEventListener('keyup', () => updateState('comment', elements.commentInput.textContent)); + elements.commentInput.addEventListener('paste', () => { + setTimeout(() => updateState('comment', elements.commentInput.textContent), 10); + }); + elements.labelSize.addEventListener('change', () => { applyLabelPreviewVisibility(); updatePreview(state.releaseData); }); + + // QR SKU toggle: switch between release_id and SKU for preview + const qrSkuToggle = document.getElementById('qr-use-sku'); + if (qrSkuToggle) { + qrSkuToggle.addEventListener('change', () => { + if (!state.releaseData) return; + if (qrSkuToggle.checked) { + // Prefer existing Discogs SKU (reprint), else use the one generated at popup open + state.releaseData.sku = state.releaseData.collectionSku || state.sku; + } else { + delete state.releaseData.sku; + } + updatePreview(state.releaseData); + }); + } + + document.querySelectorAll('.info-field').forEach(makeEditable); + + document.getElementById('printLabel').addEventListener('click', () => generateLabel(state.releaseData)); + document.getElementById('printLabel203').addEventListener('click', () => generateLabel203dpi()); + document.getElementById('writeJSON').addEventListener('click', async (e) => { + if (e.shiftKey) { + // Shift+click = upload JSON and merge into state + const data = await uploadJSON(); + if (data) { + state.releaseData = { ...state.releaseData, ...data }; + displayReleaseInfo(state.releaseData); + updatePreview(state.releaseData); + updateResearchTab(); + showStatusMessage('allStatus', 'JSON loaded from file', true); + } + } else { + writeJSON(state.releaseData); + } + }); + + // BP JSON button — copy current Beatport token JSON to clipboard + const bpJsonBtn = document.getElementById('copyBeatportJson'); + if (bpJsonBtn) { + bpJsonBtn.addEventListener('click', () => { + chrome.storage.local.get(['beatportToken'], (result) => { + const tokenJson = result.beatportToken; + if (!tokenJson) { + showStatusMessage('allStatus', 'No Beatport token saved — fetch it in Settings first', false); + return; + } + navigator.clipboard.writeText(tokenJson).then(() => { + const orig = bpJsonBtn.textContent; + bpJsonBtn.textContent = 'Copied!'; + setTimeout(() => { bpJsonBtn.textContent = orig; }, 1500); + }).catch(() => { + showStatusMessage('allStatus', 'Clipboard write failed', false); + }); + }); + }); + } + document.getElementById('updateSheet').addEventListener('click', async () => { + // console.log('=== GOOGLE SHEETS BUTTON CLICKED ==='); + // console.log('Current state.releaseData:', state.releaseData); + + if (state.releaseData) { + const result = await updateSheetOnly(state.releaseData); + showStatusMessage('sheetStatus', result.message, result.success); + } + }); + document.getElementById('bothActions').addEventListener('click', async () => await handleAllActions(state.releaseData)); + + // RFID quick-READ from Discogs tab — shows what's currently on the tag without writing + const rfidReadBtn = document.getElementById('rfidQuickRead'); + if (rfidReadBtn) { + rfidReadBtn.addEventListener('click', async () => { + const rfidEl = document.getElementById('rfidStatus'); + if (rfidEl) { + rfidEl.style.color = '#888'; + rfidEl.style.whiteSpace = 'pre-line'; + rfidEl.style.fontSize = '11px'; + rfidEl.style.lineHeight = '1.4'; + rfidEl.textContent = 'Reading tag…'; + } + rfidReadBtn.disabled = true; + const orig = rfidReadBtn.textContent; + rfidReadBtn.textContent = '…'; + const tag = await readRfidTagOnce(); + rfidReadBtn.disabled = false; + rfidReadBtn.textContent = orig; + if (rfidEl) { + if (!tag) { + rfidEl.textContent = '✗ No tag in field (or daemon not running)'; + rfidEl.style.color = '#dc3545'; + } else { + // Compare against the current Discogs page if available + const expectedSku = (state.releaseData?.collectionSku || state.sku || '').replace(/-[AR]$/i, ''); + const expectedRel = state.releaseData?.id || null; + const matchSku = expectedSku && tag.sku === expectedSku; + const matchRel = expectedRel && tag.releaseId == expectedRel; + let line2 = ''; + if (expectedSku) { + line2 = `\nexpect: ${expectedSku}${expectedRel ? ' + rel ' + expectedRel : ''}`; + if (matchSku && matchRel) line2 += ' ✓ MATCH'; + else if (matchSku) line2 += ' ⚠ sku ok, rel mismatch'; + else line2 += ' ✗ DIFFERENT'; + } + rfidEl.textContent = `tag: ${formatTag(tag)}${line2}`; + rfidEl.style.color = (matchSku && matchRel) ? '#28a745' : (matchSku ? '#d29922' : '#888'); + } + } + }); + } + + // RFID quick-write from Discogs tab + const rfidQuickBtn = document.getElementById('rfidQuickWrite'); + if (rfidQuickBtn) { + rfidQuickBtn.addEventListener('click', async () => { + const rawSku = (state.releaseData?.collectionSku) || state.sku; + if (!rawSku) { + showStatusMessage('allStatus', 'No SKU — open a Discogs release first', false); + return; + } + const sku = rawSku ? rawSku.replace(/-[AR]$/i, '') : rawSku; + rfidQuickBtn.disabled = true; + const orig = rfidQuickBtn.textContent; + rfidQuickBtn.textContent = '…'; + const releaseId = state.releaseData?.id || null; + const rfidEl = document.getElementById('rfidStatus'); + if (rfidEl) { rfidEl.style.color = '#888'; rfidEl.style.whiteSpace = 'pre-line'; rfidEl.style.fontSize = '11px'; rfidEl.style.lineHeight = '1.4'; } + + // 1. PRE-SCAN — what's on the tag right now? + if (rfidEl) rfidEl.textContent = 'Reading current tag…'; + const before = await readRfidTagOnce(); + if (rfidEl && before) { + rfidEl.textContent = `pre: ${formatTag(before)}\nwriting…`; + } + + // 2. WRITE + const result = await writeRfidTag(sku, releaseId); + + // 3. POST-SCAN — confirm what's on the tag now + const after = result?.ok ? await readRfidTagOnce() : null; + + rfidQuickBtn.disabled = false; + rfidQuickBtn.textContent = orig; + if (rfidEl) { + if (!result) { + rfidEl.textContent = `pre: ${before ? formatTag(before) : '?'}\n✗ RFID: daemon not running`; + rfidEl.style.color = '#dc3545'; + } else if (result.ok) { + rfidEl.textContent = `pre: ${before ? formatTag(before) : '?'}\n✓ wrote ${sku}${releaseId ? ' + rel ' + releaseId : ''}\npost: ${after ? formatTag(after) : '?'}`; + rfidEl.style.color = '#28a745'; + } else { + rfidEl.textContent = `pre: ${before ? formatTag(before) : '?'}\n✗ RFID: ${result.error}`; + rfidEl.style.color = '#dc3545'; + } + } + if (result?.ok) { + const appendR = await new Promise(resolve => chrome.storage.local.get(['rfidAppendR'], r => resolve(r.rfidAppendR === true))); + if (appendR && state.discogsTabId) { + try { + await chrome.tabs.sendMessage(state.discogsTabId, { action: 'updateDiscogsSku', sku: sku + '-R', boxIndex: state.collectionBoxIndex }); + } catch (e) { console.warn('[RFID] -R SKU update failed:', e.message); } + } + } + }); + } + + // Website Inventory Sync + const importBtn = document.getElementById('importWordPress'); + if (importBtn) { + importBtn.addEventListener('click', handleImportInventory); + } +} + +// Function to update button states based on available settings +async function updateButtonStates() { + if (!chrome || !chrome.storage) { + return; // Skip in preview mode + } + + return new Promise((resolve) => { + chrome.storage.local.get(['discogsToken', 'googleSettings', 'wpSettings'], (result) => { + const hasDiscogsToken = result.discogsToken && result.discogsToken.trim() !== ''; + const hasGoogleSettings = result.googleSettings && + result.googleSettings.spreadsheetId && + result.googleSettings.clientEmail && + result.googleSettings.privateKey; + const hasWpSettings = result.wpSettings && + result.wpSettings.endpoint && result.wpSettings.endpoint.trim() !== '' && + result.wpSettings.key && result.wpSettings.key.trim() !== '' && + result.wpSettings.secret && result.wpSettings.secret.trim() !== ''; + + // Update Google Sheets button + const sheetButton = document.getElementById('updateSheet'); + const bothButton = document.getElementById('bothActions'); + + if (sheetButton) { + if (hasGoogleSettings) { + sheetButton.disabled = false; + sheetButton.style.opacity = '1'; + sheetButton.title = 'Update Google Sheets'; + } else { + sheetButton.disabled = true; + sheetButton.style.opacity = '0.5'; + sheetButton.title = 'Google Sheets not configured - go to Settings tab'; + } + } + + // Update "Both Actions" button behavior + if (bothButton) { + if (hasGoogleSettings) { + bothButton.title = 'Print Label + Update Google Sheets'; + } else { + bothButton.title = 'Print Label + Save JSON (Google Sheets not configured)'; + } + } + + // console.log('Button states updated:', { hasDiscogsToken, hasGoogleSettings, hasWpSettings }); + resolve(); + }); + }); +} + +// Settings validation functions +async function checkSettingsConfigured() { + // Check if we're in an extension context + if (!chrome || !chrome.storage) { + // console.log('Not in extension context, assuming settings not configured'); + return false; + } + + return new Promise((resolve) => { + chrome.storage.local.get(['discogsToken', 'googleSettings', 'wpSettings'], (result) => { + // console.log('=== DETAILED SETTINGS DEBUG ==='); + // console.log('Raw storage result:', result); + // console.log('discogsToken value:', result.discogsToken); + // console.log('googleSettings value:', result.googleSettings); + // console.log('wpSettings value:', result.wpSettings); + + const hasDiscogsToken = result.discogsToken && result.discogsToken.trim() !== ''; + // console.log('hasDiscogsToken check:', hasDiscogsToken); + + const hasGoogleSettings = result.googleSettings && + result.googleSettings.spreadsheetId && + result.googleSettings.clientEmail && + result.googleSettings.privateKey; + // console.log('hasGoogleSettings check:', hasGoogleSettings); + if (result.googleSettings) { + // console.log('Google settings breakdown:', { + // spreadsheetId: result.googleSettings.spreadsheetId, + // clientEmail: result.googleSettings.clientEmail, + // privateKey: result.googleSettings.privateKey ? 'PRESENT' : 'MISSING' + // }); + } + + const hasWpSettings = result.wpSettings && + result.wpSettings.endpoint && result.wpSettings.endpoint.trim() !== '' && + result.wpSettings.key && result.wpSettings.key.trim() !== '' && + result.wpSettings.secret && result.wpSettings.secret.trim() !== ''; + // console.log('hasWpSettings check:', hasWpSettings); + + // Discogs token is essential - that's the minimum requirement + // Google Sheets and WordPress are optional enhancements + const hasValidSettings = hasDiscogsToken; + // console.log('Final validation result:', { hasDiscogsToken, hasGoogleSettings, hasWpSettings, hasValidSettings }); + // console.log('Settings validation: Discogs token is the only requirement'); + // console.log('=== END SETTINGS DEBUG ==='); + resolve(hasValidSettings); + }); + }); +} + +function displayStaticInterface() { + const container = document.querySelector('.container'); + if (container) { + container.innerHTML = ` +
+

⚙️ Settings Required

+

Please configure your Discogs token to use the extension:

+
    +
  • Discogs Token: Required for API access
  • +
  • Google Sheets: Optional - for logging data to spreadsheets
  • +
  • WordPress: Optional - for additional integrations
  • +
+

Click the settings button below to get started.

+
+ +
+
+ `; + + // Add event listener for the settings button + document.getElementById('open-settings-btn').addEventListener('click', () => { + document.getElementById('settings-toggle-connect').click(); + }); + } +} + +function displayLimitedInterface() { + const container = document.querySelector('.container'); + if (container) { + container.innerHTML = ` +
+

🔧 Extension Ready

+

Your settings are configured! However, some features require being on a Discogs release page.

+
+

✅ Available Features:

+
    +
  • View and edit your settings
  • +
  • YouTube cookie management
  • +
+

📋 To use full features:

+
    +
  • Navigate to a Discogs release page
  • +
  • Click the extension icon
  • +
  • Use Print Label, Save JSON, and Update Sheet buttons
  • +
+
+
+ +
+
+ `; + + // Add event listener for the settings button + document.getElementById('open-settings-btn').addEventListener('click', () => { + document.getElementById('settings-toggle-connect').click(); + }); + } +} + +async function showMasterPageStatus(masterId, tabId) { + const container = document.querySelector('.container') || document.getElementById('discogs-tab'); + if (container) { + container.innerHTML = ` +
+
Master Release
+
Checking MONSTERWIKI sync…
+
`; + } + + const cfg = await getMonsterSettings(); + const statusEl = document.getElementById('master-status-msg'); + + if (!cfg.enabled) { + if (statusEl) statusEl.textContent = 'MONSTERWIKI sync off — enable in Settings → MONSTERWIKI'; + return; + } + + if (statusEl) statusEl.textContent = '⟳ Scraping reviews…'; + + // Inject directly into the master tab — no content script message timing issues + let reviews = []; + try { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: async () => { + const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + const isVisible = (el) => { + if (!el) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const expandReplyButtons = async () => { + const re = /^see\s+\d+\s+repl/i; + for (let round = 0; round < 6; round++) { + const btns = [...document.querySelectorAll('button, a, div[role="button"]')] + .filter(isVisible) + .filter(el => re.test((el.textContent || '').trim())); + if (!btns.length) return; + for (const b of btns) { + try { b.scrollIntoView({ block: 'center', inline: 'center' }); } catch {} + try { b.click(); } catch {} + await sleep(200); + } + await sleep(600); + } + }; + + // ── 1. Try __NEXT_DATA__ (fast path) ─────────────────────────── + let ndReviews = null; + try { + const nd = document.getElementById('__NEXT_DATA__'); + if (nd) { + const d = JSON.parse(nd.textContent); + const pp = d?.props?.pageProps; + + // Deep-search for any array that looks like reviews + function findReviews(obj, depth = 0) { + if (!obj || typeof obj !== 'object' || depth > 6) return null; + if (Array.isArray(obj) && obj.length && + (obj[0]?.body || obj[0]?.text || obj[0]?.rating !== undefined) && + (obj[0]?.user || obj[0]?.username)) return obj; + for (const v of Object.values(obj)) { + const found = findReviews(v, depth + 1); + if (found) return found; + } + return null; + } + + const raw = pp?.reviews?.items + || (Array.isArray(pp?.reviews) ? pp.reviews : null) + || pp?.master?.reviews?.items + || pp?.initialState?.reviews?.items + || findReviews(pp) + || []; + + console.log('[MONSTER] __NEXT_DATA__ keys:', Object.keys(pp || {}), + '| reviews found via paths:', raw.length); + + if (raw.length) { + const mapped = raw.map(r => ({ + username: r.user?.username || r.username || null, + date: (r.submitted_at || r.submittedAt || r.date || '').split('T')[0] || null, + rating: r.rating ?? null, + text: (r.body || r.text || '').trim() || null, + helpful: r.helpful_count || r.helpfulCount || 0, + version_ref: r.version?.description || null, + version_id: r.version?.id || null, + replies: (r.replies || []).map(rep => ({ + username: rep.user?.username || rep.username || null, + date: (rep.submitted_at || rep.date || '').split('T')[0] || null, + text: (rep.body || rep.text || '').trim() || null, + })), + })).filter(r => r.text || r.rating); + // Discogs rarely embeds replies in __NEXT_DATA__ — only use as fast-path + // if replies are actually present; otherwise fall through for DOM scrape. + if (mapped.some(r => r.replies?.length > 0)) return mapped; + ndReviews = mapped; + } + } + } catch (e) { + console.warn('[MONSTER] __NEXT_DATA__ error:', e.message); + } + + // Expand only "See N reply/replies" buttons (no aggressive clicking) + await expandReplyButtons(); + + // ── 1b. Discogs-specific .review_luKwE scrape (reliable reply nesting) ── + const reviewEls = document.querySelectorAll('.review_luKwE:not(.replies_r6FWL .review_luKwE)'); + if (reviewEls.length) { + const classReviews = []; + reviewEls.forEach(reviewEl => { + const username = reviewEl.querySelector('.username_N7O6q')?.innerText?.trim() || null; + const date = reviewEl.querySelector('time')?.getAttribute('datetime')?.split('T')[0] || null; + const text = reviewEl.querySelector('.markup_Cngxi')?.innerText?.trim() || null; + const ratingLbl = reviewEl.querySelector('[class*="rating"]')?.getAttribute('aria-label') || ''; + const ratingM = ratingLbl.match(/(\d+)/); + const rating = ratingM ? parseInt(ratingM[1]) : null; + const helpful = parseInt([...reviewEl.querySelectorAll('button,a')] + .map(b => b.textContent || '') + .find(t => /helpful/i.test(t))?.match(/\d+/)?.[0]) || 0; + const replies = []; + const sib = reviewEl.nextElementSibling; + if (sib?.classList?.contains('replies_r6FWL')) { + sib.querySelectorAll('.review_luKwE').forEach(replyEl => { + const ru = replyEl.querySelector('.username_N7O6q')?.innerText?.trim() || null; + const rd = replyEl.querySelector('time')?.getAttribute('datetime')?.split('T')[0] || null; + const rt = replyEl.querySelector('.markup_Cngxi')?.innerText?.trim() || null; + if (ru && rt) replies.push({ username: ru, date: rd, text: rt }); + }); + } + if (username && (text || rating)) classReviews.push({ username, date, rating, text, helpful, version_ref: null, version_id: null, replies }); + }); + if (classReviews.length) { + console.log('[MONSTER] .review_luKwE scrape:', classReviews.length, 'reviews,', + classReviews.reduce((s,r) => s + r.replies.length, 0), 'replies'); + return classReviews; + } + } + + // ── 2. Scroll all scrollable containers + window ─────────────── + // Discogs sometimes uses a scrollable div, not window + document.querySelectorAll('main, [class*="content"], [class*="Content"], [class*="layout"], [class*="Layout"]') + .forEach(el => { try { el.scrollTop = el.scrollHeight; } catch(_){} }); + window.scrollTo(0, document.body.scrollHeight); + await new Promise(r => setTimeout(r, 3000)); + + // ── 3. Structural DOM scrape (class-name agnostic) ───────────── + // Find every element that has: a /user/ link + a