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 <noreply@anthropic.com>
This commit is contained in:
commit
e5b8504bc6
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
*.bak
|
||||
prompt-prep*.txt
|
||||
pliceclogs-labelcode.txt
|
||||
__pycache__/
|
||||
*.pyc
|
||||
55
README.md
Normal file
55
README.md
Normal file
@ -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.
|
||||
1098
background.js
Normal file
1098
background.js
Normal file
File diff suppressed because it is too large
Load Diff
21
barcode.js
Normal file
21
barcode.js
Normal file
@ -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 = `<img src="${qrUrl}" alt="${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
|
||||
}
|
||||
152
blagginate-bridge.js
Normal file
152
blagginate-bridge.js
Normal file
@ -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');
|
||||
37
constants.js
Normal file
37
constants.js
Normal file
@ -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'
|
||||
}
|
||||
};
|
||||
776
content.js
Normal file
776
content.js
Normal file
@ -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
|
||||
346
deepseek.js
Normal file
346
deepseek.js
Normal file
@ -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 });
|
||||
}
|
||||
680
docs/superpowers/plans/2026-04-12-inventory-sku-lookup.md
Normal file
680
docs/superpowers/plans/2026-04-12-inventory-sku-lookup.md
Normal file
@ -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@<dbHost>`, 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
|
||||
<button id="save-connect-settings">Save Connect Settings</button>
|
||||
```
|
||||
|
||||
Insert the following block **immediately before** that line:
|
||||
|
||||
```html
|
||||
<!-- Inventory SKU Lookup settings -->
|
||||
<div style="border-top:1px solid #ddd; margin-top:10px; padding-top:10px;">
|
||||
<div style="font-size:12px; font-weight:600; color:#444; margin-bottom:6px;">Inventory SKU Lookup</div>
|
||||
<label class="settings-check-item" style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
|
||||
<input type="checkbox" id="inventorySkuEnabled">
|
||||
<span style="font-size:12px;">Auto-fill SKU on popup open</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<label for="inventory-db-host">DB Host:</label>
|
||||
<input type="text" id="inventory-db-host" placeholder="100.123.123.64">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="inventory-db-name">DB Name:</label>
|
||||
<input type="text" id="inventory-db-name" placeholder="wp_rmp_disc_">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="inventory-db-user">DB User:</label>
|
||||
<input type="text" id="inventory-db-user" autocomplete="off">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="inventory-db-pass">DB Password:</label>
|
||||
<input type="password" id="inventory-db-pass" autocomplete="off">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="inventory-ssh-key">SSH Key Path:</label>
|
||||
<input type="text" id="inventory-ssh-key" placeholder="~/.ssh/id_rsa">
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the inventory status and picker panel in the Discogs tab**
|
||||
|
||||
In `popup.html`, locate the line:
|
||||
```html
|
||||
<div id="gemini-sync-indicator" style="font-size:11px; margin-bottom:4px; display:none; transition:opacity 1.5s;"></div>
|
||||
```
|
||||
|
||||
Insert the following block **immediately after** that line:
|
||||
|
||||
```html
|
||||
<!-- Inventory SKU auto-fill: status line + mismatch picker -->
|
||||
<div id="inventory-sku-panel" style="display:none; margin-bottom:6px;">
|
||||
<div id="inventory-sku-status" style="font-size:11px; color:#555; margin-bottom:4px;"></div>
|
||||
<div id="inventory-sku-picker" style="display:none;">
|
||||
<div id="inventory-sku-cards" style="display:flex; flex-direction:column; gap:4px; margin-bottom:6px;"></div>
|
||||
<div style="font-size:11px; color:#666; margin-bottom:3px;">Write selected SKU to box:</div>
|
||||
<div id="inventory-sku-box-buttons" style="display:flex; gap:4px; flex-wrap:wrap;"></div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **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"
|
||||
```
|
||||
167
docs/superpowers/specs/2026-04-12-inventory-sku-lookup-design.md
Normal file
167
docs/superpowers/specs/2026-04-12-inventory-sku-lookup-design.md
Normal file
@ -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:<localPort>` 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": "<message>" }` 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=<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: <reason>"` — 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
|
||||
BIN
fonts/NotoSans-Regular.ttf
Normal file
BIN
fonts/NotoSans-Regular.ttf
Normal file
Binary file not shown.
513
gemini.js
Normal file
513
gemini.js
Normal file
@ -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 });
|
||||
}
|
||||
92
label.css
Normal file
92
label.css
Normal file
@ -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;
|
||||
}
|
||||
10
logo.svg
Normal file
10
logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 63 KiB |
106
manifest.json
Normal file
106
manifest.json
Normal file
@ -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": [
|
||||
"<all_urls>"
|
||||
]
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
}
|
||||
}
|
||||
262
monster_capture.js
Normal file
262
monster_capture.js
Normal file
@ -0,0 +1,262 @@
|
||||
// Shared MONSTERWIKI capture helpers — pure functions, no DOM / popup deps.
|
||||
// Loaded by popup.html (<script>) and background.js (importScripts) so the
|
||||
// passive auto-capture path and the popup path use identical parsing/shaping.
|
||||
//
|
||||
// ponytail: these were moved verbatim out of popup.js. One home, two callers.
|
||||
|
||||
function parseSalesHistoryHtml(html) {
|
||||
const statRe = /([A-Z]?\$[\d,.]+)\s*<small>(Average|Median|High|Low)<\/small>/g;
|
||||
const stats = {};
|
||||
let m;
|
||||
while ((m = statRe.exec(html)) !== null) {
|
||||
stats[m[2].toLowerCase()] = parseFloat(m[1].replace(/[^0-9.]/g, ''));
|
||||
}
|
||||
const lastSold = html.match(/Last sold on ([^<]+)/)?.[1]?.trim() || null;
|
||||
const currency = html.match(/([A-Z]{0,2}\$)[\d,.]+\s*<small>Average/)?.[1] || null;
|
||||
|
||||
const sales = [];
|
||||
const allTr = [...html.matchAll(/<tr class="([^"]*)">([\s\S]*?)<\/tr>/g)];
|
||||
let i = 0;
|
||||
while (i < allTr.length) {
|
||||
const [, cls, body] = allTr[i];
|
||||
if (cls.includes('sales-history-row')) {
|
||||
const cells = [...body.matchAll(/<td[^>]*>\s*([\s\S]*?)\s*<\/td>/g)]
|
||||
.map(td => td[1].replace(/<[^>]+>/g, '').trim());
|
||||
const sale = {
|
||||
date: cells[0] || null,
|
||||
condition: cells[1] || null,
|
||||
sleeve: cells[2] || null,
|
||||
price: cells[3] ? parseFloat(cells[3].replace(/[^0-9.]/g, '')) : null,
|
||||
price_orig: cells[4] || null,
|
||||
comment: null,
|
||||
};
|
||||
if (i + 1 < allTr.length && allTr[i + 1][1].includes('sales-history-comment')) {
|
||||
sale.comment = allTr[i + 1][2]
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/Comments:\s*/i, '')
|
||||
.trim() || null;
|
||||
i++;
|
||||
}
|
||||
sales.push(sale);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return { last_sold: lastSold, currency, ...stats, sales };
|
||||
}
|
||||
|
||||
function parseListingsHtml(html) {
|
||||
const rows = [...html.matchAll(/<tr class="shortcut_navigable[^"]*"[^>]*data-release-id="\d+"/g)];
|
||||
return rows.map((row, idx) => {
|
||||
const block = html.slice(row.index, rows[idx + 1]?.index ?? html.length);
|
||||
const condition = block.match(/item_condition[\s\S]{0,300}?<span>\s*([^\n<]+)/)?.[1]?.trim() || null;
|
||||
const sleeve = block.match(/item_sleeve_condition">\s*([^<]+)/)?.[1]?.trim() || null;
|
||||
const price_m = block.match(/data-pricevalue="([^"]+)"[^>]*>([^<]+)</);
|
||||
const conv = block.match(/converted_price[^>]*>about\s*\n?\s*([^\s<]+)/)?.[1] || null;
|
||||
const seller = block.match(/\/seller\/([^"/]+)\/profile/)?.[1] || null;
|
||||
const sellerRating = block.match(/star_rating"[^>]*aria-label="[^"]*rating\s*([\d.]+)\s*out\s*of\s*5"/i)
|
||||
? parseFloat(block.match(/star_rating"[^>]*aria-label="[^"]*rating\s*([\d.]+)\s*out\s*of\s*5"/i)[1]) : null;
|
||||
const sellerPercent = block.match(/<strong>([\d.]+)%<\/strong>/i)
|
||||
? parseFloat(block.match(/<strong>([\d.]+)%<\/strong>/i)[1]) : null;
|
||||
const location = block.match(/Ships From:<\/span>([^<]+)/)?.[1]?.trim() || null;
|
||||
const shipping = block.match(/item_shipping">[\s\S]*?\+([^\s<]+)/i)?.[1]?.trim() || null;
|
||||
const notesRaw = block.match(/item_sleeve_condition[\s\S]{0,600}?<p class="hide_mobile">\s*([\s\S]+?)<\/p>/)?.[1];
|
||||
return {
|
||||
condition, sleeve,
|
||||
price: price_m?.[2]?.trim() || null,
|
||||
price_value: price_m ? parseFloat(price_m[1]) : null,
|
||||
currency: price_m?.[2]?.replace(/[\d.,\s]/g, '').trim() || null,
|
||||
price_aud: conv, seller, seller_rating: sellerRating, seller_percent: sellerPercent,
|
||||
location, shipping,
|
||||
notes: notesRaw ? notesRaw.replace(/<[^>]+>/g, '').trim().slice(0, 400) || null : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildResearchJson(rd) {
|
||||
const have = parseInt(rd.have) || 0;
|
||||
const want = parseInt(rd.want) || 0;
|
||||
const ratio = have > 0 ? Math.round((want / have) * 100) / 100 : null;
|
||||
|
||||
const priceSugg = {};
|
||||
if (rd.priceSuggestions) {
|
||||
Object.entries(rd.priceSuggestions).forEach(([cond, data]) => {
|
||||
const v = (data && typeof data === 'object') ? data.value : data;
|
||||
if (v != null) priceSugg[cond] = v;
|
||||
});
|
||||
}
|
||||
|
||||
const tracklist = (rd.tracklist || [])
|
||||
.filter(t => t.type_ !== 'heading')
|
||||
.map(t => {
|
||||
const track = { position: t.position, title: t.title };
|
||||
if (t.duration) track.duration = t.duration;
|
||||
const artists = (t.artists || [])
|
||||
.map(a => a.name.replace(/\s*\(\d+\)$/, '').trim())
|
||||
.filter(Boolean);
|
||||
if (artists.length) track.artists = artists;
|
||||
const extra = (t.extraartists || [])
|
||||
.map(a => ({ name: a.name.replace(/\s*\(\d+\)$/, '').trim(), role: a.role }))
|
||||
.filter(a => a.name);
|
||||
if (extra.length) track.remixers = extra;
|
||||
return track;
|
||||
});
|
||||
|
||||
const cleanReview = r => ({
|
||||
...(r.username && { username: r.username }),
|
||||
...(r.date && { date: r.date }),
|
||||
...(r.rating && { rating: r.rating }),
|
||||
...(r.text && { text: r.text.trim() }),
|
||||
...(r.helpfulCount != null && r.helpfulCount > 0 && { helpful: r.helpfulCount }),
|
||||
...(r.replies?.length && { replies: r.replies.map(cleanReview) }),
|
||||
});
|
||||
const reviews = (rd.reviews || [])
|
||||
.filter(r => r.text || r.rating)
|
||||
.map(cleanReview);
|
||||
|
||||
const formats = (rd.formats || [])
|
||||
.map(f => [f.name, ...(f.descriptions || [])].filter(Boolean).join(', '));
|
||||
|
||||
const coverImage = rd.imageUrl || ((rd.images || [])[0]?.uri) || null;
|
||||
|
||||
const companies = (rd.companies || []).map(c => ({
|
||||
name: c.name,
|
||||
role: c.entity_type_name || c.entity_type,
|
||||
})).filter(c => c.name);
|
||||
|
||||
return {
|
||||
_meta: {
|
||||
generated_at: new Date().toISOString(),
|
||||
source: 'discogs',
|
||||
discogs_url: `https://www.discogs.com/release/${rd.id}`,
|
||||
generator: 'PliceCogs X YT',
|
||||
},
|
||||
release: {
|
||||
id: rd.id,
|
||||
artist: rd.artist,
|
||||
title: rd.title,
|
||||
genre: rd.genres || (rd.genre ? rd.genre.split(', ') : []),
|
||||
style: rd.styles || (rd.style ? rd.style.split(', ') : []),
|
||||
label: rd.label,
|
||||
catalog_no: (rd.labels || [])[0]?.catno || '',
|
||||
year: rd.year,
|
||||
country: rd.country,
|
||||
format: formats.join(' / '),
|
||||
tracklist,
|
||||
identifiers: rd.identifiers || [],
|
||||
companies,
|
||||
cover_image: coverImage,
|
||||
images: (rd.images || []).map(i => i.uri).filter(Boolean),
|
||||
videos: (rd.videos || []).map(v => v.uri).filter(Boolean),
|
||||
},
|
||||
market: {
|
||||
have,
|
||||
want,
|
||||
want_have_ratio: ratio,
|
||||
for_sale: rd.num_for_sale != null ? parseInt(rd.num_for_sale) : null,
|
||||
last_sold: rd.lastSold || null,
|
||||
prices: {
|
||||
low: rd.lowPrice || null,
|
||||
median: rd.medianPrice || null,
|
||||
high: rd.highPrice || null,
|
||||
},
|
||||
price_suggestions: priceSugg,
|
||||
},
|
||||
community: {
|
||||
review_count: reviews.length,
|
||||
avg_rating: rd.avg_rating || null,
|
||||
reviews,
|
||||
},
|
||||
recommendations: (rd.recommendations || [])
|
||||
.slice(0, 10)
|
||||
.map(r => typeof r === 'string' ? r : (r.artist ? `${r.artist} – ${r.title}` : r.title))
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
// Combine the Discogs API release object with the in-page DOM scrape
|
||||
// (content.js getInitialValues) + price suggestions into the `rd` shape that
|
||||
// buildResearchJson expects. Mirrors popup.js fetchAndDisplayReleaseData merge.
|
||||
// ponytail: kept here so popup + background agree; popup still does its own
|
||||
// inline merge (touching that 5k-line file is the bigger risk).
|
||||
function combineReleaseData(api, dom, priceSuggestions) {
|
||||
dom = dom || {};
|
||||
return {
|
||||
...api,
|
||||
priceSuggestions,
|
||||
artist: (api.artists || []).map(a => a.name.replace(/\(\d+\)/, '')).join(', '),
|
||||
genre: (api.genres || []).join(', '),
|
||||
style: (api.styles || []).join(', '),
|
||||
label: api.labels ? [...new Set(api.labels.map(l => l.name.replace(/\(\d+\)/, '').replace(/ Records$/, '')))].join(', ') : '',
|
||||
lowPrice: dom.lowPrice,
|
||||
medianPrice: dom.medianPrice,
|
||||
highPrice: dom.highPrice,
|
||||
lastSold: dom.lastSold,
|
||||
have: dom.have,
|
||||
want: dom.want,
|
||||
imageUrl: (dom.imageUrl && dom.imageUrl !== 'N/A') ? dom.imageUrl : null,
|
||||
num_for_sale: api.num_for_sale,
|
||||
appleId: dom.appleId || '',
|
||||
reviews: dom.reviews || [],
|
||||
recommendations: dom.recommendations || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Export for node self-check + service-worker importScripts is automatic (globals).
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { parseSalesHistoryHtml, parseListingsHtml, buildResearchJson, combineReleaseData };
|
||||
}
|
||||
|
||||
// ── self-check: `node monster_capture.js` ─────────────────────────────────────
|
||||
if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) {
|
||||
const assert = require('assert');
|
||||
|
||||
// parseSalesHistoryHtml: stats + one row with a comment
|
||||
const salesHtml = `
|
||||
A$12.50 <small>Average</small> A$10.00 <small>Median</small>
|
||||
A$20.00 <small>High</small> A$5.00 <small>Low</small>
|
||||
Last sold on 12 Mar 2026
|
||||
<tr class="sales-history-row"><td>2026-03-12</td><td>VG+</td><td>VG</td><td>A$15.00</td><td>€9</td></tr>
|
||||
<tr class="sales-history-comment"><td>Comments: nice copy</td></tr>`;
|
||||
const sh = parseSalesHistoryHtml(salesHtml);
|
||||
assert.strictEqual(sh.average, 12.5, 'average stat');
|
||||
assert.strictEqual(sh.median, 10, 'median stat');
|
||||
assert.strictEqual(sh.sales.length, 1, 'one sale row');
|
||||
assert.strictEqual(sh.sales[0].price, 15, 'sale price parsed');
|
||||
assert.strictEqual(sh.sales[0].comment, 'nice copy', 'comment attached');
|
||||
|
||||
// parseListingsHtml: one listing
|
||||
const listHtml = `
|
||||
<tr class="shortcut_navigable" data-release-id="123">
|
||||
<span class="item_condition">cond<span> Mint (M)</span></span>
|
||||
<span class="item_sleeve_condition"> Near Mint (NM)</span>
|
||||
<span data-pricevalue="33.00">A$33.00</span>
|
||||
</tr>`;
|
||||
const lst = parseListingsHtml(listHtml);
|
||||
assert.strictEqual(lst.length, 1, 'one listing');
|
||||
assert.strictEqual(lst[0].price_value, 33, 'listing price_value');
|
||||
|
||||
// buildResearchJson: shape + want/have ratio
|
||||
const rd = {
|
||||
id: 249504, artists: [{ name: 'Rick Astley (1)' }], title: 'Never Gonna Give You Up',
|
||||
genres: ['Electronic'], styles: ['Synth-pop'], labels: [{ name: 'RCA', catno: 'PB 41447' }],
|
||||
year: 1987, country: 'UK', formats: [{ name: 'Vinyl', descriptions: ['7"', '45 RPM'] }],
|
||||
have: 100, want: 50, num_for_sale: 12, lowPrice: 5, medianPrice: 10, highPrice: 20,
|
||||
reviews: [{ username: 'x', text: 'great', rating: 5 }],
|
||||
recommendations: [{ artist: 'a-ha', title: 'Take On Me' }],
|
||||
priceSuggestions: { 'Mint (M)': { value: 25 } },
|
||||
};
|
||||
const combined = combineReleaseData(rd, {
|
||||
have: 100, want: 50, lowPrice: 5,
|
||||
reviews: [{ username: 'x', text: 'great', rating: 5 }],
|
||||
recommendations: [{ artist: 'a-ha', title: 'Take On Me' }],
|
||||
}, rd.priceSuggestions);
|
||||
assert.strictEqual(combined.artist, 'Rick Astley ', 'artist (\\(\\d+\\) stripped)');
|
||||
const out = buildResearchJson(combined);
|
||||
assert.strictEqual(out.release.id, 249504, 'release id');
|
||||
assert.strictEqual(out.market.want_have_ratio, 0.5, 'want/have ratio');
|
||||
assert.strictEqual(out.market.price_suggestions['Mint (M)'], 25, 'price suggestion flattened');
|
||||
assert.strictEqual(out.community.reviews.length, 1, 'review kept');
|
||||
assert.strictEqual(out.recommendations[0], 'a-ha – Take On Me', 'recommendation formatted');
|
||||
|
||||
console.log('monster_capture self-check OK');
|
||||
}
|
||||
231
monster_master.js
Normal file
231
monster_master.js
Normal file
@ -0,0 +1,231 @@
|
||||
/**
|
||||
* MONSTERWIKI — master page content script
|
||||
* Fires on discogs.com/master/* — scrapes reviews, POSTs to ultra.
|
||||
*/
|
||||
|
||||
(async () => {
|
||||
const { monsterSettings } = await chrome.storage.local.get(['monsterSettings']);
|
||||
if (!monsterSettings?.enabled) return;
|
||||
|
||||
const masterIdMatch = window.location.pathname.match(/^\/master\/(\d+)/);
|
||||
if (!masterIdMatch) return;
|
||||
const masterId = parseInt(masterIdMatch[1]);
|
||||
|
||||
// Notify popup we're on a master page and starting
|
||||
chrome.runtime.sendMessage({ action: 'monsterMasterStatus', state: 'syncing', masterId });
|
||||
|
||||
// ── 1. Try __NEXT_DATA__ first (fast, structured) ──────────────────────
|
||||
let reviews = [];
|
||||
const nextDataEl = document.getElementById('__NEXT_DATA__');
|
||||
if (nextDataEl) {
|
||||
try {
|
||||
const data = JSON.parse(nextDataEl.textContent);
|
||||
const pp = data?.props?.pageProps;
|
||||
const raw = pp?.reviews?.items
|
||||
|| (Array.isArray(pp?.reviews) ? pp.reviews : null)
|
||||
|| pp?.master?.reviews?.items
|
||||
|| pp?.initialState?.reviews?.items
|
||||
|| [];
|
||||
reviews = 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 || r.versionDescription || null,
|
||||
version_id: r.version?.id || r.versionId || 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);
|
||||
} catch (e) {
|
||||
console.warn('[MONSTER] __NEXT_DATA__ parse failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function expandAllMasterReviews() {
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, 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 r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
};
|
||||
|
||||
const clickAll = async (matcher, maxRounds = 12) => {
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
const els = [...document.querySelectorAll('button, a, div[role="button"]')].filter(isVisible);
|
||||
const targets = els.filter(matcher);
|
||||
if (!targets.length) return;
|
||||
for (const el of targets) {
|
||||
try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch {}
|
||||
try { el.click(); } catch {}
|
||||
await sleep(250);
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
await sleep(700);
|
||||
}
|
||||
|
||||
await clickAll((el) => {
|
||||
const t = (el.textContent || '').trim().toLowerCase();
|
||||
const label = (el.getAttribute('aria-label') || '').trim().toLowerCase();
|
||||
if (!t && !label) return false;
|
||||
const s = `${t} ${label}`;
|
||||
return s.includes('load more') || s.includes('more reviews') || s.includes('show more');
|
||||
});
|
||||
|
||||
await clickAll((el) => {
|
||||
const t = (el.textContent || '').trim();
|
||||
return /^see\s+\d+\s+repl/i.test(t);
|
||||
});
|
||||
}
|
||||
|
||||
function scrapeDomReviewsWithReplies() {
|
||||
const out = [];
|
||||
|
||||
const extractRating = (el) => {
|
||||
const ratingLabel = el.querySelector('[class*="rating"], [aria-label*="rated"]')?.getAttribute?.('aria-label') || '';
|
||||
const match = ratingLabel.match(/rated.*?(\d+)\s*star/i);
|
||||
if (match) return parseInt(match[1]);
|
||||
const stars = el.querySelectorAll('[class*="star"][class*="filled"], [class*="starFilled"]');
|
||||
return stars.length || null;
|
||||
};
|
||||
|
||||
const extractHelpful = (el) => {
|
||||
const txt = [...el.querySelectorAll('button, a')]
|
||||
.map(b => b.textContent || '')
|
||||
.find(t => t.toLowerCase().includes('helpful')) || '';
|
||||
const n = txt.match(/\d+/)?.[0];
|
||||
return n ? parseInt(n) : 0;
|
||||
};
|
||||
|
||||
// Discogs release-style markup (often reused)
|
||||
const reviewEls = document.querySelectorAll('.review_luKwE:not(.replies_r6FWL .review_luKwE)');
|
||||
if (reviewEls.length) {
|
||||
reviewEls.forEach(reviewEl => {
|
||||
const username = reviewEl.querySelector('.username_N7O6q')?.innerText?.trim() || null;
|
||||
const date = reviewEl.querySelector('time')?.getAttribute('datetime')?.split('T')[0]
|
||||
|| reviewEl.querySelector('time')?.textContent?.trim()
|
||||
|| null;
|
||||
const text = reviewEl.querySelector('.markup_Cngxi')?.innerText?.trim() || null;
|
||||
const rating = extractRating(reviewEl);
|
||||
const helpful = extractHelpful(reviewEl);
|
||||
const replies = [];
|
||||
|
||||
const repliesContainer = reviewEl.nextElementSibling;
|
||||
if (repliesContainer && repliesContainer.classList.contains('replies_r6FWL')) {
|
||||
const replyEls = repliesContainer.querySelectorAll('.review_luKwE');
|
||||
replyEls.forEach(replyEl => {
|
||||
const ru = replyEl.querySelector('.username_N7O6q')?.innerText?.trim() || null;
|
||||
const rd = replyEl.querySelector('time')?.getAttribute('datetime')?.split('T')[0]
|
||||
|| replyEl.querySelector('time')?.textContent?.trim()
|
||||
|| 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)) out.push({ username, date, rating, text, helpful, version_ref: null, version_id: null, replies });
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 2. DOM scrape fallback (reviews loaded client-side) ────────────────
|
||||
// Note: expandAllMasterReviews() is NOT called here — it scrolls and clicks the page
|
||||
// automatically which interferes with normal browsing. It only runs when triggered
|
||||
// explicitly from the popup.
|
||||
if (!reviews.length || reviews.every(r => !r.replies || !r.replies.length)) {
|
||||
const domWithReplies = scrapeDomReviewsWithReplies();
|
||||
if (domWithReplies.length) {
|
||||
reviews = domWithReplies;
|
||||
} else if (!reviews.length) {
|
||||
document.querySelectorAll('[class*="review_card"], [class*="reviewCard"], .review').forEach(card => {
|
||||
const username = card.querySelector('[class*="username"], [data-username]')?.textContent?.trim()
|
||||
|| card.getAttribute('data-username') || 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"], p')
|
||||
?.textContent?.trim() || null;
|
||||
const ratingEl = card.querySelectorAll('[class*="star"][class*="filled"], [class*="starFilled"]');
|
||||
const rating = ratingEl.length || null;
|
||||
const versionEl = [...card.querySelectorAll('a, p')]
|
||||
.find(el => el.textContent.includes('referencing'));
|
||||
const version_ref = versionEl
|
||||
? versionEl.textContent.replace(/^.*referencing\s*/i, '').trim() || null
|
||||
: null;
|
||||
const helpful = parseInt(card.querySelector('[class*="helpful"]')
|
||||
?.textContent?.match(/\d+/)?.[0]) || 0;
|
||||
|
||||
const replies = [];
|
||||
const replyBlocks = card.querySelectorAll('[class*="reply"], [class*="Reply"]');
|
||||
replyBlocks.forEach(rep => {
|
||||
const ru = rep.querySelector('[class*="username"], [data-username]')?.textContent?.trim() || null;
|
||||
const rd = rep.querySelector('time')?.getAttribute('datetime')?.split('T')[0]
|
||||
|| rep.querySelector('time')?.textContent?.trim()
|
||||
|| null;
|
||||
const rt = rep.querySelector('p, [class*="body"], [class*="markup"]')?.textContent?.trim() || null;
|
||||
if (ru && rt) replies.push({ username: ru, date: rd, text: rt });
|
||||
});
|
||||
|
||||
if (username && (text || rating)) {
|
||||
reviews.push({ username, date, rating, text, helpful, version_ref, version_id: null, replies });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Master metadata ────────────────────────────────────────────────────
|
||||
const titleEl = document.querySelector('h1[class*="title"], h1');
|
||||
const artistEl = document.querySelector('[class*="artist"] a, h2 a');
|
||||
const title = titleEl?.textContent?.trim() || null;
|
||||
const artist = artistEl?.textContent?.trim() || null;
|
||||
|
||||
console.log(`[MONSTER] master/${masterId} — ${reviews.length} reviews found`);
|
||||
|
||||
if (!reviews.length) {
|
||||
chrome.runtime.sendMessage({ action: 'monsterMasterStatus', state: 'no_reviews', masterId });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Resolve server URL ─────────────────────────────────────────────────
|
||||
const base = await (async () => {
|
||||
try {
|
||||
const r = await fetch('http://localhost:5002/plice/health',
|
||||
{ signal: AbortSignal.timeout(400) });
|
||||
if (r.ok) return 'http://localhost:5002';
|
||||
} catch (_) {}
|
||||
return (monsterSettings.serverUrl || 'http://100.91.239.7:5002').replace(/\/$/, '');
|
||||
})();
|
||||
|
||||
// ── POST ───────────────────────────────────────────────────────────────
|
||||
try {
|
||||
const resp = await fetch(`${base}/plice/master`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ master_id: masterId, title, artist, url: window.location.pathname, reviews }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const ok = resp.ok;
|
||||
const d = ok ? await resp.json() : {};
|
||||
console.log(`[MONSTER] master POST ${ok ? 'ok' : 'failed'} — ${d.review_rows ?? '?'} rows`);
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'monsterMasterStatus',
|
||||
state: ok ? 'done' : 'error',
|
||||
masterId, reviewCount: d.review_rows || reviews.length,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[MONSTER] master POST failed:', e.message);
|
||||
chrome.runtime.sendMessage({ action: 'monsterMasterStatus', state: 'error', masterId });
|
||||
}
|
||||
})();
|
||||
1148
popup.html
Normal file
1148
popup.html
Normal file
File diff suppressed because it is too large
Load Diff
91
rfid-daemon/diagnostics/compare.sh
Executable file
91
rfid-daemon/diagnostics/compare.sh
Executable file
@ -0,0 +1,91 @@
|
||||
#!/bin/bash
|
||||
# Aggregates session logs across all variants and prints a comparison table.
|
||||
# Reads everything in this directory matching run-*.log (JSON Lines from
|
||||
# variants/log.js) and reports per-variant metrics.
|
||||
#
|
||||
# Usage: ./compare.sh # summarise all sessions found
|
||||
# ./compare.sh keepalive # only show keepalive sessions
|
||||
# ./compare.sh --since=7d # only sessions in the last 7 days
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
FILTER="${1:-}"
|
||||
|
||||
shopt -s nullglob
|
||||
LOGS=(run-*.log)
|
||||
|
||||
if [ ${#LOGS[@]} -eq 0 ]; then
|
||||
echo "No session logs found in $(pwd)."
|
||||
echo "Run a variant (e.g. node ../variants/keepalive.js) and price some tags first."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%-12s %-22s %8s %8s %7s %8s %9s %12s %s\n' \
|
||||
"VARIANT" "SESSION_START" "WRITES" "READS" "TIMEOUTS" "LOCKUPS" "RUNTIME_M" "WRITES/MIN" "FILE"
|
||||
printf '%-12s %-22s %8s %8s %7s %8s %9s %12s %s\n' \
|
||||
"-------" "----------------------" "------" "-----" "--------" "-------" "---------" "-----------" "----"
|
||||
|
||||
for log in "${LOGS[@]}"; do
|
||||
fname="$log"
|
||||
# extract "<variant>" from "run-<variant>-<stamp>.log"
|
||||
base="${fname#run-}"
|
||||
base="${base%.log}"
|
||||
variant="${base%-*-*}"
|
||||
stamp="${base#${variant}-}"
|
||||
|
||||
if [ -n "$FILTER" ] && [ "$variant" != "$FILTER" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
awk -v variant="$variant" -v fname="$fname" -v stamp="$stamp" '
|
||||
/"ev":"start"/ { sessionStartTs = ts() }
|
||||
/"ev":"write_ok"/ { writes++ }
|
||||
/"ev":"read_ok"/ { reads++ }
|
||||
/"ev":"timeout"/ { timeouts++ }
|
||||
/"ev":"lockup_detected"/ { lockups++ }
|
||||
/"ev":"end"/ { hasEnd = 1; runtime = extractMs() }
|
||||
function ts() {
|
||||
match($0, /"ts":"[^"]+"/);
|
||||
return substr($0, RSTART+6, RLENGTH-7);
|
||||
}
|
||||
function extractMs( m) {
|
||||
if (match($0, /"runtimeMs":[0-9]+/)) {
|
||||
return substr($0, RSTART+12, RLENGTH-12) + 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
END {
|
||||
if (!hasEnd) {
|
||||
# Active or crashed session: estimate runtime from first→last line
|
||||
runtime = -1
|
||||
}
|
||||
runtimeMin = (runtime > 0) ? runtime / 60000.0 : 0
|
||||
writesPerMin = (runtimeMin > 0) ? writes / runtimeMin : 0
|
||||
label = sessionStartTs
|
||||
if (length(label) > 22) label = substr(label, 1, 22)
|
||||
printf "%-12s %-22s %8d %8d %7d %8d %9.1f %12.2f %s\n",
|
||||
variant, label, writes+0, reads+0, timeouts+0, lockups+0,
|
||||
runtimeMin, writesPerMin, fname
|
||||
}
|
||||
' "$log"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "Per-variant aggregate:"
|
||||
printf '%-12s %8s %8s %8s %12s %s\n' "VARIANT" "WRITES" "TIMEOUT" "LOCKUPS" "WRITES/LOCK" "SESSIONS"
|
||||
printf '%-12s %8s %8s %8s %12s %s\n' "-------" "------" "-------" "-------" "-----------" "--------"
|
||||
|
||||
for variant in control keepalive preflight slowpace broadcast; do
|
||||
files=(run-${variant}-*.log)
|
||||
[ ${#files[@]} -eq 0 ] && continue
|
||||
awk -v variant="$variant" -v sessions="${#files[@]}" '
|
||||
/"ev":"write_ok"/ { writes++ }
|
||||
/"ev":"timeout"/ { timeouts++ }
|
||||
/"ev":"lockup_detected"/ { lockups++ }
|
||||
END {
|
||||
ratio = (lockups > 0) ? writes / lockups : writes
|
||||
printf "%-12s %8d %8d %8d %12.1f %s\n", variant, writes+0, timeouts+0, lockups+0, ratio, sessions
|
||||
}
|
||||
' "${files[@]}"
|
||||
done
|
||||
5
rfid-daemon/diagnostics/known-good-config.txt
Normal file
5
rfid-daemon/diagnostics/known-good-config.txt
Normal file
@ -0,0 +1,5 @@
|
||||
2026-04-26 11:06:33 AEST
|
||||
--- Device info ---
|
||||
{"ok":true,"hwVer":"67.80","firmVer":"45.50","sn":"31 33 39 31","moduleVer":"48.95","moduleName":"V1.2UHF Hand Reader V1.2.09.0499\fK924","raw":"43 50 2D 32 31 33 39 31 30 5F 56 31 2E 32 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 55 48 46 20 48 61 6E 64 20 52 65 61 64 65 72 20 56 31 2E 32 2E 30 39 2E 30 34 00 00 00 00 00 00 FF FF FF FF 39 39 0C 00 4B 39 32 34 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"}
|
||||
--- Config ---
|
||||
{"ok":true,"raw":"00 00 00 80 04 00 01 01 03 86 02 EE 01 F4 31 1A 01 04 00 00 0C 00 0A 01 00","addr":0,"rfidPro":0,"rfidProName":"ISO 18000-6C","workMode":0,"workModeName":"answer","interface":128,"interfaceName":"RS232","baudRate":4,"baudRateName":"115200","ant":0,"qValue":1,"session":1,"inquiryArea":3,"acsAddr":134,"acsDataLen":2,"filterTime":238,"triggerTime":1,"rfidPower":244,"buzzerTime":49,"pollingInterval":26,"rfidFreq":"01 04 00 00 0C 00 0A 01","rfidFreqName":"US 902-927MHz","wgSet":0}
|
||||
1439
rfid-daemon/index.js
Normal file
1439
rfid-daemon/index.js
Normal file
File diff suppressed because it is too large
Load Diff
1444
rfid-daemon/package-lock.json
generated
Normal file
1444
rfid-daemon/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
rfid-daemon/package.json
Normal file
17
rfid-daemon/package.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"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",
|
||||
"node-hid": "^3.1.0"
|
||||
}
|
||||
}
|
||||
688
rfid-daemon/plice_server.py
Normal file
688
rfid-daemon/plice_server.py
Normal file
@ -0,0 +1,688 @@
|
||||
"""
|
||||
PliceCogs → MONSTERWIKI receiver server.
|
||||
|
||||
Listens on 0.0.0.0:5002/plice for POST requests from the PliceCogs Chrome
|
||||
extension whenever a record is priced. Writes:
|
||||
- release_market — live market snapshot (have/want/prices/suggestions)
|
||||
- release_review — full review text + ratings (unique per release/user/date)
|
||||
- collection_pricing — the pricing action itself (condition, price, SKU, folder)
|
||||
|
||||
Run: python3 plice_server.py
|
||||
(keep running in background — e.g. screen/tmux on stupendo)
|
||||
"""
|
||||
|
||||
import json
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
import socket
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
DB_NAME = "discogs_full"
|
||||
PORT = 5002
|
||||
|
||||
# Detect if we are running on 'ultra'
|
||||
HOSTNAME = socket.gethostname()
|
||||
IS_ULTRA = (HOSTNAME == 'ultra.local' or HOSTNAME == 'ultra')
|
||||
ULTRA_TAILSCALE_IP = '100.91.239.7'
|
||||
|
||||
def get_conn():
|
||||
if IS_ULTRA:
|
||||
# Local connection on ultra
|
||||
return psycopg2.connect(dbname=DB_NAME)
|
||||
else:
|
||||
# Remote connection to ultra via Tailscale
|
||||
return psycopg2.connect(
|
||||
dbname=DB_NAME,
|
||||
host=ULTRA_TAILSCALE_IP,
|
||||
user='johnking' # Assuming johnking as per standard local setup
|
||||
)
|
||||
|
||||
|
||||
def ensure_tables(conn):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
-- Full-res cover image on the release row (not in XML dump)
|
||||
ALTER TABLE release ADD COLUMN IF NOT EXISTS cover_image TEXT;
|
||||
ALTER TABLE release ADD COLUMN IF NOT EXISTS images JSONB;
|
||||
|
||||
-- Live market snapshot per release (one row per release, upserted)
|
||||
CREATE TABLE IF NOT EXISTS release_market (
|
||||
release_id INTEGER PRIMARY KEY,
|
||||
have INTEGER,
|
||||
want INTEGER,
|
||||
for_sale INTEGER,
|
||||
low_price NUMERIC(10,2),
|
||||
median_price NUMERIC(10,2),
|
||||
high_price NUMERIC(10,2),
|
||||
last_sold TEXT,
|
||||
price_suggestions JSONB,
|
||||
fetched_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Community reviews (one row per review, unique by release+user+date)
|
||||
CREATE TABLE IF NOT EXISTS release_review (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
review_date TEXT,
|
||||
rating INTEGER,
|
||||
review_text TEXT,
|
||||
helpful_count INTEGER,
|
||||
replies JSONB,
|
||||
fetched_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE (release_id, username, review_date)
|
||||
);
|
||||
|
||||
-- Every pricing action recorded (append-only log)
|
||||
CREATE TABLE IF NOT EXISTS collection_pricing (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL,
|
||||
artist TEXT,
|
||||
title TEXT,
|
||||
label TEXT,
|
||||
year INTEGER,
|
||||
media_condition TEXT,
|
||||
sleeve_condition TEXT,
|
||||
price TEXT,
|
||||
sku TEXT,
|
||||
folder TEXT,
|
||||
comment TEXT,
|
||||
priced_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Reviews from master pages (all versions aggregated)
|
||||
CREATE TABLE IF NOT EXISTS master_review (
|
||||
id SERIAL PRIMARY KEY,
|
||||
master_id INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
review_date TEXT,
|
||||
rating INTEGER,
|
||||
review_text TEXT,
|
||||
helpful INTEGER,
|
||||
version_ref TEXT, -- "referencing X LP ZEN88" description string
|
||||
version_id INTEGER, -- Discogs release ID if available
|
||||
replies JSONB,
|
||||
fetched_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE (master_id, username, review_date)
|
||||
);
|
||||
|
||||
-- Individual sales history rows (append-only; de-duped by release+date+price)
|
||||
CREATE TABLE IF NOT EXISTS release_sale (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL,
|
||||
sale_date TEXT,
|
||||
condition TEXT,
|
||||
sleeve TEXT,
|
||||
price NUMERIC(10,2),
|
||||
price_orig TEXT,
|
||||
currency TEXT,
|
||||
comment TEXT,
|
||||
fetched_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE (release_id, sale_date, price)
|
||||
);
|
||||
|
||||
-- Snapshot of current marketplace listings at time of capture
|
||||
CREATE TABLE IF NOT EXISTS release_listing_snapshot (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL,
|
||||
condition TEXT,
|
||||
sleeve TEXT,
|
||||
price TEXT,
|
||||
price_value NUMERIC(10,2),
|
||||
currency TEXT,
|
||||
seller TEXT,
|
||||
seller_rating NUMERIC(3,1),
|
||||
seller_percent NUMERIC(5,2),
|
||||
location TEXT,
|
||||
shipping TEXT,
|
||||
notes TEXT,
|
||||
snapshot_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Historical record of market price changes over time (append-only)
|
||||
-- A new row is inserted only when have/want/for_sale or any price changes.
|
||||
CREATE TABLE IF NOT EXISTS release_market_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL,
|
||||
have INTEGER,
|
||||
want INTEGER,
|
||||
for_sale INTEGER,
|
||||
low_price NUMERIC(10,2),
|
||||
median_price NUMERIC(10,2),
|
||||
high_price NUMERIC(10,2),
|
||||
last_sold TEXT,
|
||||
recorded_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rmh_release_recorded
|
||||
ON release_market_history (release_id, recorded_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release_gemini (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL,
|
||||
prompt_text TEXT,
|
||||
input_json JSONB,
|
||||
output_text TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_release_gemini_release_created
|
||||
ON release_gemini (release_id, created_at DESC);
|
||||
|
||||
ALTER TABLE release_gemini
|
||||
ADD COLUMN IF NOT EXISTS output_json JSONB;
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass # silence default access log
|
||||
|
||||
def _cors(self):
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
self.send_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
|
||||
# Required for Chrome Private Network Access (Tailscale IPs are "private")
|
||||
self.send_header('Access-Control-Allow-Private-Network', 'true')
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(200)
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith('/plice/inventory'):
|
||||
import urllib.parse
|
||||
query = urllib.parse.urlparse(self.path).query
|
||||
params = urllib.parse.parse_qs(query)
|
||||
release_id = params.get('release_id', [None])[0]
|
||||
if not release_id:
|
||||
self.send_response(400)
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'ok': False, 'error': 'Missing release_id'}).encode())
|
||||
return
|
||||
|
||||
try:
|
||||
import subprocess
|
||||
# SSH command to query the remote VPS database
|
||||
# Table: wp_rmp_disc_inventory, Fields: release_id, sku, price
|
||||
# We sort by sku (which is a timestamp) to get the "oldest" first as requested
|
||||
ssh_cmd = [
|
||||
'ssh', 'mrpadmin@100.123.123.64',
|
||||
f"mysql -N -e \"SELECT sku, price FROM wp_rmp_disc_inventory WHERE release_id = {int(release_id)} ORDER BY sku ASC;\""
|
||||
]
|
||||
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"SSH/MySQL error: {result.stderr.strip()}")
|
||||
|
||||
lines = result.stdout.strip().split('\n')
|
||||
inventory = []
|
||||
for line in lines:
|
||||
if not line.strip(): continue
|
||||
parts = line.split('\t')
|
||||
if len(parts) >= 2:
|
||||
inventory.append({'sku': parts[0], 'price': parts[1]})
|
||||
|
||||
resp_body = json.dumps({'ok': True, 'inventory': inventory}).encode()
|
||||
self.send_response(200)
|
||||
self._cors()
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', len(resp_body))
|
||||
self.end_headers()
|
||||
self.wfile.write(resp_body)
|
||||
except Exception as e:
|
||||
print(f" ERROR (inventory): {e}")
|
||||
self.send_response(500)
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'ok': False, 'error': str(e)}).encode())
|
||||
return
|
||||
|
||||
if self.path in ('/plice/health', '/plice', '/plice/gemini'):
|
||||
body = json.dumps({'ok': True, 'server': 'plice_server'}).encode()
|
||||
self.send_response(200)
|
||||
self._cors()
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', len(body))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == '/plice/gemini':
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
rows_written = self._store_gemini(payload)
|
||||
resp_body = json.dumps({'ok': True, 'ai_rows': rows_written}).encode()
|
||||
self.send_response(200)
|
||||
self._cors()
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', len(resp_body))
|
||||
self.end_headers()
|
||||
self.wfile.write(resp_body)
|
||||
ts = datetime.now().strftime('%H:%M:%S')
|
||||
print(f"[{ts}] [gemini] release {payload.get('release_id') or (payload.get('release') or {}).get('id')}")
|
||||
except Exception as e:
|
||||
print(f" ERROR (gemini): {e}")
|
||||
self.send_response(500)
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'ok': False, 'error': str(e)}).encode())
|
||||
return
|
||||
|
||||
if self.path == '/plice/master':
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
rows_written = self._store_master(payload)
|
||||
resp_body = json.dumps({'ok': True, 'review_rows': rows_written}).encode()
|
||||
self.send_response(200)
|
||||
self._cors()
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', len(resp_body))
|
||||
self.end_headers()
|
||||
self.wfile.write(resp_body)
|
||||
ts = datetime.now().strftime('%H:%M:%S')
|
||||
print(f"[{ts}] [master] {payload.get('url','?')} "
|
||||
f"{payload.get('artist','')} — {payload.get('title','')} "
|
||||
f"→ {rows_written} reviews")
|
||||
except Exception as e:
|
||||
print(f" ERROR (master): {e}")
|
||||
self.send_response(500)
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'ok': False, 'error': str(e)}).encode())
|
||||
return
|
||||
|
||||
if self.path != '/plice':
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(length)
|
||||
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
market_rows, review_rows, pricing_rows, sale_rows, listing_rows = self._store(payload)
|
||||
|
||||
resp_body = json.dumps({
|
||||
'ok': True,
|
||||
'market_rows': market_rows,
|
||||
'review_rows': review_rows,
|
||||
'pricing_rows': pricing_rows,
|
||||
'sale_rows': sale_rows,
|
||||
'listing_rows': listing_rows,
|
||||
}).encode()
|
||||
self.send_response(200)
|
||||
self._cors()
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', len(resp_body))
|
||||
self.end_headers()
|
||||
self.wfile.write(resp_body)
|
||||
|
||||
ts = datetime.now().strftime('%H:%M:%S')
|
||||
release = payload.get('release', {})
|
||||
pricing = payload.get('pricing', {})
|
||||
print(
|
||||
f"[{ts}] release {release.get('id')} "
|
||||
f"{release.get('artist')} — {release.get('title')} "
|
||||
f"| price={pricing.get('price') or '(no price)'} "
|
||||
f"| reviews={review_rows} sales={sale_rows} listings={listing_rows} market_upsert={market_rows}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
self.send_response(500)
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'ok': False, 'error': str(e)}).encode())
|
||||
|
||||
def _store_master(self, payload):
|
||||
master_id = payload.get('master_id')
|
||||
if not master_id:
|
||||
return 0
|
||||
rows = 0
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for r in payload.get('reviews', []):
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO master_review
|
||||
(master_id, username, review_date, rating,
|
||||
review_text, helpful, version_ref, version_id,
|
||||
replies, fetched_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW())
|
||||
ON CONFLICT (master_id, username, review_date) DO UPDATE SET
|
||||
rating = EXCLUDED.rating,
|
||||
review_text = EXCLUDED.review_text,
|
||||
helpful = EXCLUDED.helpful,
|
||||
version_ref = EXCLUDED.version_ref,
|
||||
version_id = EXCLUDED.version_id,
|
||||
replies = EXCLUDED.replies,
|
||||
fetched_at = NOW()
|
||||
""", (
|
||||
master_id,
|
||||
r.get('username'),
|
||||
r.get('date'),
|
||||
r.get('rating'),
|
||||
r.get('text'),
|
||||
r.get('helpful') or 0,
|
||||
r.get('version_ref'),
|
||||
r.get('version_id'),
|
||||
json.dumps(r.get('replies') or []),
|
||||
))
|
||||
rows += 1
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
continue
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
def _store_gemini(self, payload):
|
||||
release_id = payload.get('release_id') or (payload.get('release') or {}).get('id') or payload.get('id')
|
||||
if not release_id:
|
||||
return 0
|
||||
|
||||
output_text = payload.get('output_text') or ''
|
||||
output_json = payload.get('output_json')
|
||||
if output_json is None and output_text:
|
||||
start = output_text.find('{')
|
||||
end = output_text.rfind('}')
|
||||
if start != -1 and end != -1 and end > start:
|
||||
try:
|
||||
output_json = json.loads(output_text[start:end + 1])
|
||||
except Exception:
|
||||
output_json = None
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
INSERT INTO release_gemini
|
||||
(release_id, prompt_text, input_json, output_text, output_json, created_at)
|
||||
VALUES
|
||||
(%s, %s, %s::jsonb, %s, %s::jsonb, NOW())
|
||||
""", (
|
||||
release_id,
|
||||
payload.get('prompt'),
|
||||
json.dumps(payload.get('input_json')) if payload.get('input_json') is not None else None,
|
||||
output_text,
|
||||
json.dumps(output_json) if output_json is not None else None,
|
||||
))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return 1
|
||||
|
||||
def _store(self, payload):
|
||||
release = payload.get('release', {})
|
||||
market = payload.get('market', {})
|
||||
community = payload.get('community', {})
|
||||
pricing = payload.get('pricing', {})
|
||||
sales_history = payload.get('sales_history') or {}
|
||||
curr_listings = payload.get('current_listings') or []
|
||||
|
||||
release_id = release.get('id') or payload.get('id')
|
||||
if not release_id:
|
||||
return 0, 0, 0
|
||||
|
||||
conn = get_conn()
|
||||
market_rows = review_rows = pricing_rows = 0
|
||||
sale_rows = listing_rows = 0
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# ── release.cover_image + images (not in XML dump) ────────────
|
||||
cover_image = release.get('cover_image')
|
||||
images_raw = release.get('images') # list of URIs from API
|
||||
if cover_image or images_raw:
|
||||
cur.execute("""
|
||||
UPDATE release
|
||||
SET cover_image = COALESCE(%s, cover_image),
|
||||
images = COALESCE(%s::jsonb, images)
|
||||
WHERE id = %s
|
||||
""", (
|
||||
cover_image,
|
||||
json.dumps(images_raw) if images_raw else None,
|
||||
release_id,
|
||||
))
|
||||
|
||||
# ── release_market upsert ──────────────────────────────────────
|
||||
prices = market.get('prices', {})
|
||||
|
||||
def to_numeric(v):
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return v
|
||||
# Strip currency symbols like "$12.50"
|
||||
import re
|
||||
m = re.search(r'[\d.]+', str(v))
|
||||
return float(m.group()) if m else None
|
||||
|
||||
new_have = market.get('have')
|
||||
new_want = market.get('want')
|
||||
new_for_sale= market.get('for_sale')
|
||||
new_low = to_numeric(prices.get('low'))
|
||||
new_median = to_numeric(prices.get('median'))
|
||||
new_high = to_numeric(prices.get('high'))
|
||||
new_sold = market.get('last_sold')
|
||||
new_suggestions = json.dumps(market.get('price_suggestions') or {})
|
||||
|
||||
# Read previous values to detect changes
|
||||
cur.execute("""
|
||||
SELECT have, want, for_sale, low_price, median_price, high_price, last_sold
|
||||
FROM release_market WHERE release_id = %s
|
||||
""", (release_id,))
|
||||
prev = cur.fetchone()
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO release_market
|
||||
(release_id, have, want, for_sale,
|
||||
low_price, median_price, high_price,
|
||||
last_sold, price_suggestions, fetched_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (release_id) DO UPDATE SET
|
||||
have = EXCLUDED.have,
|
||||
want = EXCLUDED.want,
|
||||
for_sale = EXCLUDED.for_sale,
|
||||
low_price = EXCLUDED.low_price,
|
||||
median_price = EXCLUDED.median_price,
|
||||
high_price = EXCLUDED.high_price,
|
||||
last_sold = EXCLUDED.last_sold,
|
||||
price_suggestions = EXCLUDED.price_suggestions,
|
||||
fetched_at = NOW()
|
||||
""", (
|
||||
release_id,
|
||||
new_have, new_want, new_for_sale,
|
||||
new_low, new_median, new_high,
|
||||
new_sold, new_suggestions,
|
||||
))
|
||||
market_rows = 1
|
||||
|
||||
# Append to history only when something actually changed
|
||||
def _changed(a, b):
|
||||
if a is None and b is None:
|
||||
return False
|
||||
return str(a) != str(b)
|
||||
|
||||
is_first_scan = prev is None
|
||||
if is_first_scan or any(_changed(*p) for p in [
|
||||
(new_have, prev[0]),
|
||||
(new_want, prev[1]),
|
||||
(new_for_sale, prev[2]),
|
||||
(new_low, prev[3]),
|
||||
(new_median, prev[4]),
|
||||
(new_high, prev[5]),
|
||||
(new_sold, prev[6]),
|
||||
]):
|
||||
cur.execute("""
|
||||
INSERT INTO release_market_history
|
||||
(release_id, have, want, for_sale,
|
||||
low_price, median_price, high_price,
|
||||
last_sold, recorded_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
""", (
|
||||
release_id,
|
||||
new_have, new_want, new_for_sale,
|
||||
new_low, new_median, new_high, new_sold,
|
||||
))
|
||||
|
||||
# ── release_review upsert ──────────────────────────────────────
|
||||
for rev in community.get('reviews', []):
|
||||
username = rev.get('username') or ''
|
||||
rev_date = rev.get('date') or ''
|
||||
rating_raw = rev.get('rating')
|
||||
rating = int(rating_raw) if rating_raw is not None else None
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO release_review
|
||||
(release_id, username, review_date, rating,
|
||||
review_text, helpful_count, replies, fetched_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (release_id, username, review_date) DO UPDATE SET
|
||||
rating = EXCLUDED.rating,
|
||||
review_text = EXCLUDED.review_text,
|
||||
helpful_count = EXCLUDED.helpful_count,
|
||||
replies = EXCLUDED.replies,
|
||||
fetched_at = NOW()
|
||||
""", (
|
||||
release_id,
|
||||
username,
|
||||
rev_date,
|
||||
rating,
|
||||
rev.get('text') or '',
|
||||
rev.get('helpful') or 0,
|
||||
json.dumps(rev.get('replies') or []),
|
||||
))
|
||||
review_rows += 1
|
||||
except Exception as re:
|
||||
conn.rollback()
|
||||
print(f" review insert error: {re}")
|
||||
continue
|
||||
|
||||
# ── release_sale upsert ───────────────────────────────────────
|
||||
currency = sales_history.get('currency')
|
||||
for sale in sales_history.get('sales', []):
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO release_sale
|
||||
(release_id, sale_date, condition, sleeve,
|
||||
price, price_orig, currency, comment, fetched_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (release_id, sale_date, price) DO NOTHING
|
||||
""", (
|
||||
release_id,
|
||||
sale.get('date'),
|
||||
sale.get('condition'),
|
||||
sale.get('sleeve'),
|
||||
sale.get('price'),
|
||||
sale.get('price_orig'),
|
||||
currency,
|
||||
sale.get('comment'),
|
||||
))
|
||||
sale_rows += 1
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
continue
|
||||
|
||||
# ── release_listing_snapshot — append only if listings changed ──
|
||||
if curr_listings:
|
||||
# Compare seller set against most recent snapshot to avoid duplicate entries
|
||||
cur.execute("""
|
||||
SELECT seller, price, condition
|
||||
FROM release_listing_snapshot
|
||||
WHERE release_id = %s
|
||||
AND snapshot_at = (
|
||||
SELECT MAX(snapshot_at) FROM release_listing_snapshot
|
||||
WHERE release_id = %s
|
||||
)
|
||||
ORDER BY seller, price
|
||||
""", (release_id, release_id))
|
||||
prev_snap = {(r[0], r[1], r[2]) for r in cur.fetchall()}
|
||||
new_snap = {(l.get('seller'), l.get('price'), l.get('condition'))
|
||||
for l in curr_listings}
|
||||
listings_changed = (prev_snap != new_snap)
|
||||
|
||||
if listings_changed:
|
||||
for lst in curr_listings:
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO release_listing_snapshot
|
||||
(release_id, condition, sleeve, price, price_value,
|
||||
currency, seller, seller_rating, seller_percent,
|
||||
location, shipping, notes, snapshot_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW())
|
||||
""", (
|
||||
release_id,
|
||||
lst.get('condition'),
|
||||
lst.get('sleeve'),
|
||||
lst.get('price'),
|
||||
lst.get('price_value'),
|
||||
lst.get('currency'),
|
||||
lst.get('seller'),
|
||||
lst.get('seller_rating'),
|
||||
lst.get('seller_percent'),
|
||||
lst.get('location'),
|
||||
lst.get('shipping'),
|
||||
lst.get('notes'),
|
||||
))
|
||||
listing_rows += 1
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
continue
|
||||
|
||||
# ── collection_pricing append ──────────────────────────────────
|
||||
if pricing:
|
||||
cur.execute("""
|
||||
INSERT INTO collection_pricing
|
||||
(release_id, artist, title, label, year,
|
||||
media_condition, sleeve_condition,
|
||||
price, sku, folder, comment, priced_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
""", (
|
||||
release_id,
|
||||
release.get('artist'),
|
||||
release.get('title'),
|
||||
release.get('label'),
|
||||
release.get('year'),
|
||||
pricing.get('media_condition'),
|
||||
pricing.get('sleeve_condition'),
|
||||
pricing.get('price'),
|
||||
pricing.get('sku'),
|
||||
pricing.get('folder'),
|
||||
pricing.get('comment'),
|
||||
))
|
||||
pricing_rows = 1
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return market_rows, review_rows, pricing_rows, sale_rows, listing_rows
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
conn = get_conn()
|
||||
ensure_tables(conn)
|
||||
conn.close()
|
||||
print(f"PliceCogs server listening on 0.0.0.0:{PORT}")
|
||||
if IS_ULTRA:
|
||||
print(f"Running LOCALLY on {HOSTNAME}. Database connection is local.")
|
||||
print(f"Extension should use http://localhost:{PORT}/plice")
|
||||
else:
|
||||
print(f"Running REMOTELY on {HOSTNAME}. Database connection → {ULTRA_TAILSCALE_IP}")
|
||||
print(f"Extension should use http://{ULTRA_TAILSCALE_IP}:{PORT}/plice")
|
||||
|
||||
HTTPServer(('0.0.0.0', PORT), Handler).serve_forever()
|
||||
154
rfid-daemon/probe.js
Normal file
154
rfid-daemon/probe.js
Normal file
@ -0,0 +1,154 @@
|
||||
'use strict';
|
||||
// Chafon H102 probe: scan baud rates, attempt GET_ALL_PARAM, attempt SET interface to CDC_COM
|
||||
|
||||
const { SerialPort } = require('serialport');
|
||||
const PORT = process.env.RFID_PORT || '/dev/cu.usbserial-5130';
|
||||
const BAUDS = [115200, 9600, 38400, 57600, 19200];
|
||||
|
||||
function crc16(buf) {
|
||||
let crc = 0xFFFF;
|
||||
for (const b of buf) {
|
||||
crc ^= b;
|
||||
for (let i = 0; i < 8; i++)
|
||||
crc = (crc & 1) ? ((crc >> 1) ^ 0x8408) : (crc >> 1);
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
function frame(cmd, data = Buffer.alloc(0)) {
|
||||
const hdr = Buffer.from([0xCF, 0x00, cmd[0], cmd[1], data.length]);
|
||||
const body = Buffer.concat([hdr, data]);
|
||||
const chk = crc16(body);
|
||||
const f = Buffer.concat([body, Buffer.from([(chk >> 8) & 0xFF, chk & 0xFF])]);
|
||||
console.log(` TX: ${f.toString('hex').replace(/../g,'$& ').trim().toUpperCase()}`);
|
||||
return f;
|
||||
}
|
||||
|
||||
const CMD_INVENTORY = [0x00, 0x01];
|
||||
const CMD_GET_PARAM = [0x00, 0x72];
|
||||
const CMD_SET_PARAM = [0x00, 0x71];
|
||||
|
||||
// AllParamBean minimal structure to set interface to CDC_COM (0x04)
|
||||
// Based on SDK doc: mInterface byte is first param byte.
|
||||
// We send a minimal known-safe config: keep everything default, just flip interface.
|
||||
// Structure (from SDK AllParamBean): interface(1) baud(1) addr(1) power(1) ...
|
||||
// We'll use GET_PARAM first to read existing values then patch interface byte.
|
||||
|
||||
function tryPort(baud) {
|
||||
return new Promise(resolve => {
|
||||
console.log(`\n── Testing ${baud} baud ──`);
|
||||
const port = new SerialPort({ path: PORT, baudRate: baud, autoOpen: false });
|
||||
port.open(err => {
|
||||
if (err) { console.log(` open error: ${err.message}`); return resolve(null); }
|
||||
|
||||
let buf = Buffer.alloc(0);
|
||||
let resolved = false;
|
||||
const done = (result) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
port.close(() => resolve(result));
|
||||
};
|
||||
|
||||
port.on('data', chunk => {
|
||||
const hex = chunk.toString('hex').replace(/../g,'$& ').trim().toUpperCase();
|
||||
const asc = chunk.toString().replace(/[^\x20-\x7E]/g, '.');
|
||||
console.log(` RX: ${hex} "${asc}"`);
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
});
|
||||
|
||||
// Send inventory first (simplest command)
|
||||
port.write(frame(CMD_INVENTORY));
|
||||
|
||||
setTimeout(() => {
|
||||
if (buf.length > 0) {
|
||||
console.log(` ✓ Got ${buf.length} bytes at ${baud} baud`);
|
||||
done({ baud, buf });
|
||||
} else {
|
||||
console.log(` ✗ No response at ${baud} baud`);
|
||||
done(null);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getAndPatchConfig(baud) {
|
||||
return new Promise(resolve => {
|
||||
console.log(`\n── GET_ALL_PARAM at ${baud} baud ──`);
|
||||
const port = new SerialPort({ path: PORT, baudRate: baud, autoOpen: false });
|
||||
port.open(err => {
|
||||
if (err) return resolve(false);
|
||||
|
||||
let buf = Buffer.alloc(0);
|
||||
port.on('data', chunk => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
const hex = chunk.toString('hex').replace(/../g,'$& ').trim().toUpperCase();
|
||||
console.log(` RX: ${hex}`);
|
||||
});
|
||||
|
||||
port.write(frame(CMD_GET_PARAM));
|
||||
|
||||
setTimeout(() => {
|
||||
if (buf.length < 7) {
|
||||
console.log(' No config response received');
|
||||
port.close(() => resolve(false));
|
||||
return;
|
||||
}
|
||||
|
||||
// Response: CF ADDR CMD_H CMD_L LEN STATUS DATA... CRC_H CRC_L
|
||||
// DATA is the AllParamBean bytes — patch interface byte (index 0 of data = STATUS+0)
|
||||
// status = buf[5], param data starts at buf[6]
|
||||
const status = buf[5];
|
||||
console.log(` GET_PARAM status: 0x${status.toString(16)}`);
|
||||
if (status !== 0x00) {
|
||||
port.close(() => resolve(false));
|
||||
return;
|
||||
}
|
||||
|
||||
const paramData = buf.slice(6, buf.length - 2); // strip CRC
|
||||
console.log(` Param bytes: ${paramData.toString('hex').replace(/../g,'$& ').trim().toUpperCase()}`);
|
||||
console.log(` Current interface byte: 0x${paramData[0]?.toString(16)}`);
|
||||
|
||||
// Patch interface byte to 0x04 (CDC_COM serial mode)
|
||||
paramData[0] = 0x04;
|
||||
console.log(` Setting interface to 0x04 (CDC_COM)...`);
|
||||
port.write(frame(CMD_SET_PARAM, paramData));
|
||||
|
||||
setTimeout(() => {
|
||||
const hex2 = buf.slice(paramData.length + 9).toString('hex').replace(/../g,'$& ').trim().toUpperCase();
|
||||
console.log(` SET_PARAM response raw: ${hex2 || '(none yet)'}`);
|
||||
port.close(() => resolve(true));
|
||||
}, 2000);
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Probing ${PORT}`);
|
||||
console.log('Place a tag on the reader if you have one handy.\n');
|
||||
|
||||
let workingBaud = null;
|
||||
for (const baud of BAUDS) {
|
||||
const result = await tryPort(baud);
|
||||
if (result) { workingBaud = result.baud; break; }
|
||||
}
|
||||
|
||||
if (!workingBaud) {
|
||||
console.log('\n━━━ No response at any baud rate ━━━');
|
||||
console.log('Device is likely in USB-HID keyboard wedge mode.');
|
||||
console.log('Serial port (CH340) is present but firmware is not routing data to it.');
|
||||
console.log('\nFix: use Chafon CfTech Android app over BLE to change Interface setting');
|
||||
console.log(' to "CDC_COM" (0x04) or "USB" (0x01), then reconnect USB.');
|
||||
console.log('\nSafer option: use one of the shop Android tablets (not your Pixel).');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n✓ Device communicating at ${workingBaud} baud`);
|
||||
await getAndPatchConfig(workingBaud);
|
||||
console.log('\nDone. If SET_PARAM succeeded, unplug and replug the H102 USB.');
|
||||
console.log(`Then restart the daemon with: RFID_BAUD=${workingBaud} node index.js`);
|
||||
console.log('(or update BAUD_RATE in index.js if it differs from 115200)');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
165
rfid-daemon/recover.js
Normal file
165
rfid-daemon/recover.js
Normal file
@ -0,0 +1,165 @@
|
||||
'use strict';
|
||||
/**
|
||||
* H102 recovery script.
|
||||
* Sends RFM_SET_GET_READMODE to switch back to RFID mode.
|
||||
* Also tries factory-reset (RFM_REBOOT) if needed.
|
||||
* Run: node recover.js
|
||||
*/
|
||||
|
||||
const { SerialPort } = require('serialport');
|
||||
|
||||
const PORT = process.env.RFID_PORT || '/dev/cu.usbserial-4130';
|
||||
const BAUDS = [115200, 9600, 19200, 38400, 57600];
|
||||
|
||||
function crc16(buf) {
|
||||
let crc = 0xFFFF;
|
||||
for (const b of buf) {
|
||||
crc ^= b;
|
||||
for (let i = 0; i < 8; i++) crc = (crc & 1) ? ((crc >> 1) ^ 0x8408) : (crc >> 1);
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
function frame(cmd, data = Buffer.alloc(0), addr = 0xFF) {
|
||||
const hdr = Buffer.from([0xCF, addr, cmd[0], cmd[1], data.length]);
|
||||
const body = Buffer.concat([hdr, data]);
|
||||
const chk = crc16(body);
|
||||
return Buffer.concat([body, Buffer.from([(chk >> 8) & 0xFF, chk & 0xFF])]);
|
||||
}
|
||||
|
||||
const hex = b => Buffer.from(b).toString('hex').replace(/../g, '$& ').trim().toUpperCase();
|
||||
|
||||
// Commands
|
||||
const CMD_GET_PARAM = [0x00, 0x72];
|
||||
const CMD_REBOOT = [0x00, 0x52];
|
||||
const CMD_READMODE = [0x00, 0x8E];
|
||||
|
||||
// RFM_SET_GET_READMODE: set RFID mode (0x00 = RFID, 0x01 = barcode/QR)
|
||||
// SET format: OPTION(0x01=set) + READMODE(1) + RECEV(7 reserved zeros)
|
||||
const SET_RFID_MODE = frame(CMD_READMODE, Buffer.from([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]));
|
||||
const GET_READMODE = frame(CMD_READMODE, Buffer.from([0x02])); // OPTION=0x02 = read
|
||||
|
||||
const FACTORY_RESET = frame(CMD_REBOOT, Buffer.alloc(0));
|
||||
const GET_PARAM = frame(CMD_GET_PARAM, Buffer.alloc(0));
|
||||
|
||||
function tryAtBaud(baud, targetPort) {
|
||||
return new Promise(resolve => {
|
||||
console.log(`\n── Trying ${baud} baud on ${targetPort} ──`);
|
||||
const port = new SerialPort({ path: targetPort, baudRate: baud, autoOpen: false });
|
||||
let resolved = false;
|
||||
|
||||
port.open(err => {
|
||||
if (err) {
|
||||
console.log(` open failed: ${err.message}`);
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
let rxBuf = Buffer.alloc(0);
|
||||
|
||||
port.on('data', chunk => {
|
||||
rxBuf = Buffer.concat([rxBuf, chunk]);
|
||||
console.log(` RX: ${hex(chunk)}`);
|
||||
});
|
||||
|
||||
const done = ok => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
setTimeout(() => port.close(() => resolve(ok)), 200);
|
||||
};
|
||||
|
||||
// Step 1: GET_ALL_PARAM — checks if device responds to serial at all
|
||||
console.log(` TX GET_PARAM: ${hex(GET_PARAM)}`);
|
||||
port.write(GET_PARAM);
|
||||
|
||||
setTimeout(() => {
|
||||
if (rxBuf.length === 0) {
|
||||
console.log(` No response at ${baud} baud — trying next`);
|
||||
return done(false);
|
||||
}
|
||||
|
||||
console.log(`\n ✓ Device responding at ${baud} baud!`);
|
||||
console.log(` Full RX so far: ${hex(rxBuf)}`);
|
||||
|
||||
rxBuf = Buffer.alloc(0);
|
||||
|
||||
// Step 2: GET current READMODE
|
||||
console.log(`\n TX GET_READMODE: ${hex(GET_READMODE)}`);
|
||||
port.write(GET_READMODE);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(` READMODE response: ${hex(rxBuf)}`);
|
||||
// rxBuf[5]=STATUS, rxBuf[6]=Option, rxBuf[7]=READMODE value
|
||||
const readModeVal = rxBuf[7];
|
||||
if (readModeVal === 0x01) {
|
||||
console.log('\n !! Device is in BARCODE/QR mode (READMODE=0x01).');
|
||||
console.log(' Switching back to RFID mode...');
|
||||
} else {
|
||||
console.log(`\n READMODE = 0x${(readModeVal||0).toString(16)} — may already be RFID mode, sending SET anyway`);
|
||||
}
|
||||
|
||||
rxBuf = Buffer.alloc(0);
|
||||
|
||||
// Step 3: SET RFID mode
|
||||
console.log(`\n TX SET_RFID_MODE: ${hex(SET_RFID_MODE)}`);
|
||||
port.write(SET_RFID_MODE);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(` SET_RFID_MODE response: ${hex(rxBuf)}`);
|
||||
const setStatus = rxBuf[5];
|
||||
if (setStatus === 0x00) {
|
||||
console.log('\n ✓ RFID mode restored! Unplug and replug the H102, then restart the daemon.');
|
||||
} else {
|
||||
console.log(`\n SET_RFID_MODE returned status 0x${(setStatus||0).toString(16)}`);
|
||||
console.log(' Trying factory reset...');
|
||||
rxBuf = Buffer.alloc(0);
|
||||
|
||||
console.log(`\n TX FACTORY_RESET: ${hex(FACTORY_RESET)}`);
|
||||
port.write(FACTORY_RESET);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(` FACTORY_RESET response: ${hex(rxBuf)}`);
|
||||
console.log('\n Factory reset sent. Unplug and replug the H102.');
|
||||
done(true);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
done(true);
|
||||
}, 2000);
|
||||
}, 1500);
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
port.on('error', err => {
|
||||
console.log(` port error: ${err.message}`);
|
||||
if (!resolved) done(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Check if port exists
|
||||
const ports = await SerialPort.list();
|
||||
console.log('Available ports:');
|
||||
ports.forEach(p => console.log(` ${p.path} (${p.manufacturer || 'unknown'})`));
|
||||
|
||||
const usbPort = ports.find(p => /usbserial|usbmodem|ttyUSB|ttyACM/i.test(p.path));
|
||||
const targetPort = process.env.RFID_PORT || (usbPort ? usbPort.path.replace('/dev/tty.', '/dev/cu.') : PORT);
|
||||
console.log(`\nTargeting: ${targetPort}`);
|
||||
|
||||
for (const baud of BAUDS) {
|
||||
const ok = await tryAtBaud(baud, targetPort);
|
||||
if (ok) return;
|
||||
}
|
||||
|
||||
console.log('\n━━━ No serial response at any baud rate ━━━');
|
||||
console.log('The device may have switched to USB-HID keyboard mode entirely.');
|
||||
console.log('\nHardware factory reset options for H102:');
|
||||
console.log(' 1. Hold the scan TRIGGER button for 8-10 seconds while powered on');
|
||||
console.log(' 2. Hold POWER + TRIGGER simultaneously for 5 seconds');
|
||||
console.log(' 3. Check the back/bottom for a reset pinhole (use a paperclip)');
|
||||
console.log(' 4. Connect via BLE: power cycle the device, then within 30 seconds');
|
||||
console.log(' open the CfTech app → "BLE" → scan — the device only advertises');
|
||||
console.log(' for ~30 seconds after power-on');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
7
rfid-daemon/start.bat
Normal file
7
rfid-daemon/start.bat
Normal file
@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
REM Windows: double-click to start the RFID daemon
|
||||
REM If auto-detect fails, uncomment and set your COM port:
|
||||
REM set RFID_PORT=COM4
|
||||
cd /d "%~dp0"
|
||||
node index.js
|
||||
pause
|
||||
5
rfid-daemon/start.command
Executable file
5
rfid-daemon/start.command
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# macOS: double-click this file to start the RFID daemon
|
||||
# (right-click → Open the first time to allow execution)
|
||||
cd "$(dirname "$0")"
|
||||
node index.js
|
||||
6
rfid-daemon/start.sh
Executable file
6
rfid-daemon/start.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Linux: run once after fresh install to grant serial port access, then logout/in
|
||||
# sudo usermod -a -G dialout $USER
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
node index.js
|
||||
792
rfid-daemon/variants/_core.js
Normal file
792
rfid-daemon/variants/_core.js
Normal file
@ -0,0 +1,792 @@
|
||||
'use strict';
|
||||
// Parameterised core for the variant experiment.
|
||||
// Behaves identically to ../index.js when started with no flags. Each flag
|
||||
// adds ONE feature change so variants can be A/B compared. Don't run this
|
||||
// directly — use one of variants/{control,keepalive,preflight,slowpace,broadcast}.js.
|
||||
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { SerialPort } = require('serialport');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { Client: SshClient } = require('ssh2');
|
||||
const mysql = require('mysql2/promise');
|
||||
const { createLogger } = require('./log');
|
||||
|
||||
function startDaemon(config) {
|
||||
const { variant } = config;
|
||||
const flags = config.flags || {};
|
||||
const HOSTNAME = os.hostname();
|
||||
const IS_ULTRA = (HOSTNAME === 'ultra.local' || HOSTNAME === 'ultra');
|
||||
|
||||
const QUIET = process.argv.includes('--quiet') || process.argv.includes('-q');
|
||||
const verbose = QUIET ? () => {} : (...a) => console.log(...a);
|
||||
|
||||
const HTTP_PORT = parseInt(process.env.RFID_HTTP_PORT || '7790', 10);
|
||||
const DEFAULT_INVENTORY_HOST = '100.123.123.64';
|
||||
const DEFAULT_SSH_USER = 'mrpadmin';
|
||||
const BAUD_RATE = parseInt(process.env.RFID_BAUD || '115200', 10);
|
||||
|
||||
const ADDR = flags.broadcast ? 0xFF : 0x00;
|
||||
const KEEPALIVE_MS = flags.keepalive ? 10000 : 0;
|
||||
const SLOWPACE_TX_MS = flags.slowpace ? 500 : 0;
|
||||
const SLOWPACE_POSTWRITE_MS = flags.slowpace ? 2000 : 0;
|
||||
|
||||
const diagDir = path.join(__dirname, '..', 'diagnostics');
|
||||
const logger = createLogger(variant, diagDir);
|
||||
console.log(`[rfid:${variant}] flags=${JSON.stringify(flags)} addr=0x${ADDR.toString(16).padStart(2,'0')}`);
|
||||
console.log(`[rfid:${variant}] logging session events to ${logger.logPath}`);
|
||||
logger.event('start', { variant, flags, port: HTTP_PORT, addr: ADDR, baud: BAUD_RATE });
|
||||
|
||||
const CMD_INVENTORY = [0x00, 0x01];
|
||||
const CMD_STOP = [0x00, 0x02];
|
||||
const CMD_READ = [0x00, 0x03];
|
||||
const CMD_WRITE = [0x00, 0x04];
|
||||
const CMD_LOCK = [0x00, 0x05];
|
||||
const CMD_SELECTMASK = [0x00, 0x07];
|
||||
const CMD_GET_PARAM = [0x00, 0x72];
|
||||
const CMD_SET_PARAM = [0x00, 0x71];
|
||||
const CMD_DEVICE_INFO = [0x00, 0x70];
|
||||
const CMD_BATTERY = [0x00, 0x83];
|
||||
const MEM_EPC = 0x01;
|
||||
const DEFAULT_ACCESS_PWD = Buffer.from([0x00, 0x00, 0x00, 0x00]);
|
||||
|
||||
let serial = null;
|
||||
let connectedPath = null;
|
||||
let readerOnline = false;
|
||||
let lastTxAt = 0;
|
||||
|
||||
function requireReaderOnline() {
|
||||
if (!readerOnline) throw new Error('Reader offline — press the trigger button on the gun to wake it, then click Recover Reader.');
|
||||
}
|
||||
|
||||
// ── Serial port discovery ────────────────────────────────────────────────
|
||||
async function findPort() {
|
||||
if (process.env.RFID_PORT) return process.env.RFID_PORT;
|
||||
const ports = await SerialPort.list();
|
||||
console.log(`[rfid:${variant}] available ports:`, ports.map(p => `${p.path} (${p.manufacturer || 'unknown'})`).join(', ') || 'none');
|
||||
const macMatch = ports.find(p => /\/dev\/tty\.usbserial/i.test(p.path));
|
||||
if (macMatch) return macMatch.path.replace('/dev/tty.', '/dev/cu.');
|
||||
const match = ports.find(p =>
|
||||
/ttyUSB/i.test(p.path) ||
|
||||
/ttyACM/i.test(p.path) ||
|
||||
(/COM\d+/i.test(p.path) && /ch34|wch|qinheng|ftdi|prolific|silicon/i.test(p.manufacturer || ''))
|
||||
);
|
||||
return match ? match.path : null;
|
||||
}
|
||||
|
||||
async function openSerial(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const port = new SerialPort({ path, baudRate: BAUD_RATE, autoOpen: false });
|
||||
port.open(err => err ? reject(err) : resolve(port));
|
||||
});
|
||||
}
|
||||
|
||||
let _connecting = null;
|
||||
async function ensureConnected() {
|
||||
if (serial?.isOpen) return;
|
||||
if (!_connecting) {
|
||||
_connecting = (async () => {
|
||||
const path = await findPort();
|
||||
if (!path) throw new Error('Chafon reader not found. Check USB connection.');
|
||||
serial = await openSerial(path);
|
||||
connectedPath = path;
|
||||
console.log(`[rfid:${variant}] connected to ${path} @ ${BAUD_RATE} baud`);
|
||||
logger.event('connected', { path, baud: BAUD_RATE });
|
||||
serial.on('data', chunk => {
|
||||
const hex = chunk.toString('hex').replace(/../g, '$& ').trim().toUpperCase();
|
||||
const ascii = chunk.toString('ascii').replace(/[^\x20-\x7E]/g, '.');
|
||||
verbose(`[rfid:${variant}] UNSOLICITED RX: ${hex} "${ascii}"`);
|
||||
});
|
||||
serial.on('error', err => {
|
||||
console.error(`[rfid:${variant}] serial error:`, err.message);
|
||||
serial = null; connectedPath = null;
|
||||
});
|
||||
serial.on('close', () => {
|
||||
console.log(`[rfid:${variant}] port closed`);
|
||||
serial = null; connectedPath = null; readerOnline = false;
|
||||
});
|
||||
try {
|
||||
await sendCommand(buildFrame(CMD_DEVICE_INFO, Buffer.alloc(0)), 1500);
|
||||
await autoConfigureAnswerMode();
|
||||
} catch (_) {
|
||||
console.log(`[rfid:${variant}] reader not responding — press the trigger button to wake it, then click Recover Reader.`);
|
||||
}
|
||||
})().finally(() => { _connecting = null; });
|
||||
}
|
||||
return _connecting;
|
||||
}
|
||||
|
||||
function flushSerial() {
|
||||
return new Promise(resolve => serial.flush(() => resolve()));
|
||||
}
|
||||
|
||||
async function autoConfigureAnswerMode() {
|
||||
try {
|
||||
let stopped = false;
|
||||
for (let i = 0; i < 5 && !stopped; i++) {
|
||||
await flushSerial();
|
||||
try {
|
||||
await sendCommand(buildStopFrame(), 600);
|
||||
stopped = true;
|
||||
} catch (_) { /* keep trying */ }
|
||||
}
|
||||
if (!stopped) {
|
||||
console.log(`[rfid:${variant}] reader not responding to STOP — it may be asleep.`);
|
||||
return;
|
||||
}
|
||||
await flushSerial();
|
||||
const cfgResp = await sendCommand(buildFrame(CMD_GET_PARAM, Buffer.alloc(0)), 2000);
|
||||
if (cfgResp.status !== 0x00) return;
|
||||
const workMode = cfgResp.data[2];
|
||||
const modeNames = { 0: 'answer', 1: 'active', 2: 'trigger' };
|
||||
console.log(`[rfid:${variant}] reader work mode: ${modeNames[workMode] ?? workMode}`);
|
||||
logger.event('workmode_observed', { workMode, name: modeNames[workMode] ?? String(workMode) });
|
||||
if (workMode === 0) return;
|
||||
console.log(`[rfid:${variant}] switching reader to answer mode...`);
|
||||
const params = Buffer.from(cfgResp.data);
|
||||
params[2] = 0;
|
||||
const setResp = await sendCommand(buildFrame(CMD_SET_PARAM, params), 3000);
|
||||
if (setResp.status === 0x00) {
|
||||
console.log(`[rfid:${variant}] answer mode set`);
|
||||
logger.event('workmode_set', { to: 0 });
|
||||
} else {
|
||||
console.log(`[rfid:${variant}] warning: could not set answer mode (status 0x${setResp.status.toString(16)})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[rfid:${variant}] note: could not read/set reader work mode:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CRC-16 / framing ─────────────────────────────────────────────────────
|
||||
function crc16(buf) {
|
||||
let crc = 0xFFFF;
|
||||
for (const byte of buf) {
|
||||
crc ^= byte;
|
||||
for (let i = 0; i < 8; i++) crc = (crc & 1) ? ((crc >> 1) ^ 0x8408) : (crc >> 1);
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
function buildFrame(cmd2, data) {
|
||||
const header = Buffer.from([0xCF, ADDR, cmd2[0], cmd2[1], data.length]);
|
||||
const body = Buffer.concat([header, data]);
|
||||
const chk = crc16(body);
|
||||
return Buffer.concat([body, Buffer.from([(chk >> 8) & 0xFF, chk & 0xFF])]);
|
||||
}
|
||||
|
||||
function buildInventoryFrame(invType = 0x00, invParam = 1) {
|
||||
return buildFrame(CMD_INVENTORY, Buffer.from([invType, 0x00, 0x00, 0x00, invParam & 0xFF]));
|
||||
}
|
||||
function buildStopFrame() { return buildFrame(CMD_STOP, Buffer.alloc(0)); }
|
||||
|
||||
function buildSelectMaskFrame(epcBytes) {
|
||||
if (!epcBytes || epcBytes.length === 0) {
|
||||
return buildFrame(CMD_SELECTMASK, Buffer.from([0x00, 0x00, 0x00]));
|
||||
}
|
||||
const lengthBits = epcBytes.length * 8;
|
||||
return buildFrame(CMD_SELECTMASK, Buffer.concat([
|
||||
Buffer.from([0x00, 0x00, lengthBits & 0xFF]),
|
||||
epcBytes
|
||||
]));
|
||||
}
|
||||
|
||||
function buildWriteFrame(memBank, wordPtr, dataBytes) {
|
||||
if (dataBytes.length % 2 !== 0) throw new Error('Data must be word-aligned (even bytes)');
|
||||
const wordCount = dataBytes.length / 2;
|
||||
const data = Buffer.concat([
|
||||
Buffer.from([0x00]),
|
||||
DEFAULT_ACCESS_PWD,
|
||||
Buffer.from([memBank, (wordPtr >> 8) & 0xFF, wordPtr & 0xFF, wordCount]),
|
||||
dataBytes
|
||||
]);
|
||||
return buildFrame(CMD_WRITE, data);
|
||||
}
|
||||
|
||||
function buildReadFrame(memBank, wordPtr, wordCount) {
|
||||
const data = Buffer.concat([
|
||||
Buffer.from([0x00]),
|
||||
DEFAULT_ACCESS_PWD,
|
||||
Buffer.from([memBank, (wordPtr >> 8) & 0xFF, wordPtr & 0xFF, wordCount])
|
||||
]);
|
||||
return buildFrame(CMD_READ, data);
|
||||
}
|
||||
|
||||
// ── EPC encoding ─────────────────────────────────────────────────────────
|
||||
const RELID_FACTOR = 1_000_000_000n;
|
||||
function buildEpcPayload(sku, releaseId) {
|
||||
const combined = BigInt(sku) * RELID_FACTOR + BigInt(releaseId || 0);
|
||||
const buf = Buffer.alloc(12, 0);
|
||||
let tmp = combined;
|
||||
for (let i = 11; i >= 0; i--) { buf[i] = Number(tmp & 0xFFn); tmp >>= 8n; }
|
||||
return buf;
|
||||
}
|
||||
|
||||
function parseEpcData(buf) {
|
||||
let value = 0n;
|
||||
for (const b of buf) value = (value << 8n) | BigInt(b);
|
||||
const releaseRaw = Number(value % RELID_FACTOR);
|
||||
const sku = (value / RELID_FACTOR).toString().padStart(14, '0');
|
||||
return { sku, releaseId: releaseRaw === 0 ? null : releaseRaw };
|
||||
}
|
||||
|
||||
function parseEpcFromInventoryData(data) {
|
||||
if (!data || data.length < 17) return null;
|
||||
return data.slice(5, 17);
|
||||
}
|
||||
|
||||
// ── Serial command runner ────────────────────────────────────────────────
|
||||
function hexDump(buf) {
|
||||
return Buffer.from(buf).toString('hex').replace(/../g, '$& ').trim().toUpperCase();
|
||||
}
|
||||
|
||||
async function maybeSlowpaceTxGap() {
|
||||
if (SLOWPACE_TX_MS > 0) {
|
||||
const since = Date.now() - lastTxAt;
|
||||
const wait = SLOWPACE_TX_MS - since;
|
||||
if (wait > 0) await new Promise(r => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
|
||||
function sendCommand(frame, timeoutMs = 5000, skipStatuses = []) {
|
||||
return (async () => {
|
||||
await maybeSlowpaceTxGap();
|
||||
verbose(`[rfid:${variant}] TX: ${hexDump(frame)}`);
|
||||
const expectedCmd = [frame[2], frame[3]];
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (fn, val) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
serial?.removeListener('data', onData);
|
||||
serial?.removeListener('close', onClose);
|
||||
fn(val);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
logger.event('timeout', { cmd: `0x${expectedCmd[0].toString(16).padStart(2,'0')}${expectedCmd[1].toString(16).padStart(2,'0')}`, timeoutMs });
|
||||
settle(reject, new Error('RFID reader timeout — is a tag present on the reader?'));
|
||||
}, timeoutMs);
|
||||
const onClose = () => settle(reject, new Error('Serial port closed unexpectedly'));
|
||||
let buf = Buffer.alloc(0);
|
||||
function onData(chunk) {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
verbose(`[rfid:${variant}] RX chunk: ${hexDump(chunk)} (buf ${buf.length} bytes)`);
|
||||
while (buf.length >= 7) {
|
||||
const start = buf.indexOf(0xCF);
|
||||
if (start < 0) { buf = Buffer.alloc(0); return; }
|
||||
if (start > 0) buf = buf.slice(start);
|
||||
if (buf.length < 7) return;
|
||||
const dataLen = buf[4];
|
||||
const totalLen = 5 + dataLen + 2;
|
||||
if (buf.length < totalLen) return;
|
||||
const respFrame = buf.slice(0, totalLen);
|
||||
buf = buf.slice(totalLen);
|
||||
const cmdH = respFrame[2];
|
||||
const cmdL = respFrame[3];
|
||||
if (cmdH !== expectedCmd[0] || cmdL !== expectedCmd[1]) {
|
||||
verbose(`[rfid:${variant}] RX skip (CMD=${cmdH.toString(16).padStart(2,'0')}${cmdL.toString(16).padStart(2,'0')} != expected ${expectedCmd[0].toString(16).padStart(2,'0')}${expectedCmd[1].toString(16).padStart(2,'0')})`);
|
||||
continue;
|
||||
}
|
||||
const status = respFrame[5];
|
||||
if (skipStatuses.includes(status)) {
|
||||
verbose(`[rfid:${variant}] RX intermediate status=0x${status.toString(16).padStart(2,'0')}`);
|
||||
continue;
|
||||
}
|
||||
verbose(`[rfid:${variant}] RX full: ${hexDump(respFrame)}`);
|
||||
readerOnline = true;
|
||||
const data = respFrame.slice(6, totalLen - 2);
|
||||
settle(resolve, { status, data });
|
||||
return;
|
||||
}
|
||||
}
|
||||
serial.on('data', onData);
|
||||
serial.on('close', onClose);
|
||||
serial.write(frame, err => {
|
||||
lastTxAt = Date.now();
|
||||
if (err) settle(reject, err);
|
||||
});
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
// ── Serial mutex ─────────────────────────────────────────────────────────
|
||||
let _serialBusy = Promise.resolve();
|
||||
function withSerial(fn) {
|
||||
const ticket = _serialBusy.then(() => fn());
|
||||
_serialBusy = ticket.catch(() => {});
|
||||
return ticket;
|
||||
}
|
||||
|
||||
// ── Tag operations ───────────────────────────────────────────────────────
|
||||
const EPC_WORD_PTR = 0x0002;
|
||||
const SKU_WORD_COUNT = 6;
|
||||
|
||||
async function clearMask() {
|
||||
try {
|
||||
await sendCommand(buildSelectMaskFrame(null), 2000);
|
||||
verbose(`[rfid:${variant}] SELECTMASK cleared`);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function sendStop() {
|
||||
if (!serial?.isOpen) return;
|
||||
try { await sendCommand(buildStopFrame(), 500); } catch (_) {}
|
||||
}
|
||||
|
||||
// Preflight: re-assert answer mode before each tag op. Cheaper than the
|
||||
// full autoConfigureAnswerMode (no STOP retry loop).
|
||||
async function preflight() {
|
||||
if (!flags.preflight) return;
|
||||
try {
|
||||
await sendStop();
|
||||
const cfg = await sendCommand(buildFrame(CMD_GET_PARAM, Buffer.alloc(0)), 1200).catch(() => null);
|
||||
if (!cfg || cfg.status !== 0x00) {
|
||||
logger.event('preflight_run', { ok: false, reason: 'no_config_response' });
|
||||
return;
|
||||
}
|
||||
const wm = cfg.data[2];
|
||||
if (wm !== 0) {
|
||||
const params = Buffer.from(cfg.data);
|
||||
params[2] = 0;
|
||||
await sendCommand(buildFrame(CMD_SET_PARAM, params), 2000).catch(() => {});
|
||||
logger.event('preflight_run', { ok: true, hadDrift: true, fromMode: wm });
|
||||
} else {
|
||||
logger.event('preflight_run', { ok: true, hadDrift: false });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.event('preflight_run', { ok: false, reason: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function primeRF() {
|
||||
await _runInventory();
|
||||
await sendStop();
|
||||
}
|
||||
|
||||
async function _runInventory() {
|
||||
requireReaderOnline();
|
||||
await sendStop();
|
||||
let invResp;
|
||||
try {
|
||||
invResp = await sendCommand(buildInventoryFrame(), 3000);
|
||||
} catch (err) {
|
||||
if (!err.message.includes('timeout')) throw err;
|
||||
console.log(`[rfid:${variant}] reader not responding — retrying...`);
|
||||
await sendStop();
|
||||
try {
|
||||
invResp = await sendCommand(buildInventoryFrame(), 3000);
|
||||
} catch (_) {
|
||||
throw new Error('Reader asleep — press the trigger button on the gun to wake it, then try again');
|
||||
}
|
||||
}
|
||||
if (invResp.status !== 0x00) throw new Error('No tag found — place tag on reader');
|
||||
return invResp;
|
||||
}
|
||||
|
||||
const LOCK_PAYLOAD_UNLOCK_EPC = Buffer.from([0x00, 0xC0, 0x00]);
|
||||
function buildLockFrame(lockPayload) {
|
||||
const data = Buffer.concat([
|
||||
Buffer.from([0x00]),
|
||||
DEFAULT_ACCESS_PWD,
|
||||
lockPayload
|
||||
]);
|
||||
return buildFrame(CMD_LOCK, data);
|
||||
}
|
||||
|
||||
async function unlockEpcBank() {
|
||||
await primeRF();
|
||||
const frame = buildLockFrame(LOCK_PAYLOAD_UNLOCK_EPC);
|
||||
const resp = await sendCommand(frame, 8000, [0x14]);
|
||||
if (resp.status !== 0x00 && resp.status !== 0x12) {
|
||||
const codes = { 0x01: 'Parameter error', 0x13: 'No tag found', 0x17: 'Wrong password — access pwd is not 00000000' };
|
||||
throw new Error(`Unlock failed: ${codes[resp.status] || `status 0x${resp.status.toString(16)}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSkuToTag(sku, releaseId) {
|
||||
const startedAt = Date.now();
|
||||
requireReaderOnline();
|
||||
if (!/^\d{14}$/.test(sku)) throw new Error(`Invalid SKU: "${sku}" (must be 14 digits)`);
|
||||
await preflight();
|
||||
const epcData = buildEpcPayload(sku, releaseId);
|
||||
const codes = { 0x01: 'Parameter error', 0x09: 'Wrong access password',
|
||||
0x12: 'No tag found (keep tag on reader)',
|
||||
0x13: 'No tag found', 0x17: 'Wrong password' };
|
||||
console.log(`[rfid:${variant}] writing EPC: ${epcData.toString('hex').toUpperCase()} (sku=${sku} relId=${releaseId || 0})`);
|
||||
let written = false;
|
||||
const MAX_WRITE_ATTEMPTS = 3;
|
||||
for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS && !written; attempt++) {
|
||||
await clearMask();
|
||||
await sendStop();
|
||||
let currentEpc = null;
|
||||
try {
|
||||
const invResp = await sendCommand(buildInventoryFrame(), 4000);
|
||||
if (invResp.status === 0x00) currentEpc = parseEpcFromInventoryData(invResp.data);
|
||||
} catch (_) {}
|
||||
await sendStop();
|
||||
if (currentEpc && epcData.equals(currentEpc)) {
|
||||
console.log(`[rfid:${variant}] target EPC already on tag at attempt ${attempt + 1} — prior write committed`);
|
||||
written = true;
|
||||
break;
|
||||
}
|
||||
let resp;
|
||||
try {
|
||||
resp = await sendCommand(buildWriteFrame(MEM_EPC, EPC_WORD_PTR, epcData), 5000, [0x14]);
|
||||
} catch (err) {
|
||||
console.log(`[rfid:${variant}] write attempt ${attempt + 1} TIMEOUT`);
|
||||
if (attempt >= MAX_WRITE_ATTEMPTS - 1) throw new Error('Write timed out — keep tag flat on reader and try again');
|
||||
continue;
|
||||
}
|
||||
const statusStr = `0x${resp.status.toString(16).padStart(2,'0')}`;
|
||||
if (resp.status === 0x00) {
|
||||
console.log(`[rfid:${variant}] write → ${statusStr} OK`);
|
||||
written = true;
|
||||
} else if (resp.status === 0x12 || resp.status === 0x13) {
|
||||
console.log(`[rfid:${variant}] write attempt ${attempt + 1} → ${statusStr} (retrying)`);
|
||||
} else {
|
||||
throw new Error(`Write failed: ${codes[resp.status] || `status ${statusStr}`}`);
|
||||
}
|
||||
}
|
||||
if (!written) throw new Error(`Write failed after ${MAX_WRITE_ATTEMPTS} attempts — keep tag flat on the reader`);
|
||||
await sendCommand(buildSelectMaskFrame(epcData), 2000).catch(() => {});
|
||||
const verify = await readSkuFromTag({ skipPreflight: true });
|
||||
const wantedRelId = releaseId ? parseInt(releaseId, 10) : null;
|
||||
if (verify.sku !== sku || verify.releaseId !== wantedRelId) {
|
||||
logger.event('write_failed', { sku, releaseId, error: 'verify_mismatch', got: verify });
|
||||
throw new Error(`Verify failed: tag has sku=${verify.sku} relId=${verify.releaseId}, expected sku=${sku} relId=${wantedRelId}`);
|
||||
}
|
||||
console.log(`[rfid:${variant}] verify OK: sku=${verify.sku} relId=${verify.releaseId}`);
|
||||
if (SLOWPACE_POSTWRITE_MS > 0) {
|
||||
await new Promise(r => setTimeout(r, SLOWPACE_POSTWRITE_MS));
|
||||
}
|
||||
logger.event('write_ok', { sku, releaseId, durationMs: Date.now() - startedAt });
|
||||
return sku;
|
||||
}
|
||||
|
||||
async function readSkuFromTag(opts = {}) {
|
||||
if (!opts.skipPreflight) await preflight();
|
||||
const wordCount = SKU_WORD_COUNT;
|
||||
await primeRF();
|
||||
const frame = buildReadFrame(MEM_EPC, EPC_WORD_PTR, wordCount);
|
||||
const resp = await sendCommand(frame, 12000);
|
||||
await sendStop();
|
||||
if (resp.status !== 0x00) {
|
||||
const codes = { 0x01: 'Parameter error', 0x13: 'No tag found', 0x14: 'Tag timeout' };
|
||||
throw new Error(`Read failed: ${codes[resp.status] || `status 0x${resp.status.toString(16)}`}`);
|
||||
}
|
||||
const rssi = resp.data[0] >= 128 ? resp.data[0] - 256 : resp.data[0];
|
||||
const antenna = resp.data[1];
|
||||
const readData = resp.data.slice(resp.data.length - wordCount * 2);
|
||||
return { ...parseEpcData(readData), rssi, antenna };
|
||||
}
|
||||
|
||||
// ── HTTP server ──────────────────────────────────────────────────────────
|
||||
const app = express();
|
||||
app.use(cors({ origin: '*' }));
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/status', async (req, res) => {
|
||||
try {
|
||||
await ensureConnected();
|
||||
res.json({ ok: true, port: connectedPath, variant, flags });
|
||||
} catch (err) {
|
||||
res.status(503).json({ ok: false, error: err.message, variant });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/inventory', (req, res) => {
|
||||
withSerial(async () => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await ensureConnected();
|
||||
const invType = req.query.type === 'count' ? 0x01 : 0x00;
|
||||
const invParam = Math.min(255, Math.max(1, parseInt(req.query.param || '1', 10)));
|
||||
const resp = await sendCommand(buildInventoryFrame(invType, invParam), invType === 0x01 ? 10000 : 5000);
|
||||
sendStop();
|
||||
const statusCodes = { 0x00: 'Tag found', 0x12: 'Inventory complete', 0x13: 'No tags found', 0x14: 'Tag timeout' };
|
||||
const found = resp.status === 0x00;
|
||||
const rssi = found && resp.data.length >= 2 ? (resp.data[0] >= 128 ? resp.data[0] - 256 : resp.data[0]) : null;
|
||||
const antenna = found && resp.data.length >= 2 ? resp.data[1] : null;
|
||||
const epcBytes = found ? parseEpcFromInventoryData(resp.data) : null;
|
||||
if (found) logger.event('inventory_ok', { epc: epcBytes ? epcBytes.toString('hex').toUpperCase() : null, durationMs: Date.now() - startedAt });
|
||||
else logger.event('inventory_failed', { status: resp.status, durationMs: Date.now() - startedAt });
|
||||
res.json({
|
||||
ok: found || resp.status === 0x12,
|
||||
status: resp.status,
|
||||
statusText: statusCodes[resp.status] || `0x${resp.status.toString(16)}`,
|
||||
rssi, antenna,
|
||||
epc: epcBytes ? epcBytes.toString('hex').toUpperCase() : null,
|
||||
rawHex: hexDump(resp.data)
|
||||
});
|
||||
} catch (err) {
|
||||
sendStop();
|
||||
logger.event('inventory_failed', { error: err.message, durationMs: Date.now() - startedAt });
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/clear-mask', (req, res) => {
|
||||
withSerial(async () => {
|
||||
try {
|
||||
await ensureConnected();
|
||||
await sendStop();
|
||||
await clearMask();
|
||||
res.json({ ok: true, message: 'SELECTMASK cleared' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/recover', (req, res) => {
|
||||
withSerial(async () => {
|
||||
try {
|
||||
await ensureConnected();
|
||||
logger.event('recover_attempted');
|
||||
await autoConfigureAnswerMode();
|
||||
await clearMask();
|
||||
const pingFrame = buildFrame(CMD_DEVICE_INFO, Buffer.alloc(0));
|
||||
try {
|
||||
await sendCommand(pingFrame, 2000);
|
||||
res.json({ ok: true, message: 'Reader recovered — try again.' });
|
||||
} catch (_) {
|
||||
readerOnline = false;
|
||||
res.status(500).json({ ok: false, error: 'Reader still not responding — press the trigger button on the gun to wake it, then try again.' });
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/unlock-tag', (req, res) => {
|
||||
withSerial(async () => {
|
||||
try {
|
||||
await ensureConnected();
|
||||
await unlockEpcBank();
|
||||
res.json({ ok: true, message: 'EPC bank unlocked' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/write-tag', (req, res) => {
|
||||
const { sku } = req.body;
|
||||
const releaseId = req.body.releaseId ?? req.body.release_id ?? null;
|
||||
if (!sku) return res.status(400).json({ ok: false, error: 'Missing sku' });
|
||||
withSerial(async () => {
|
||||
try {
|
||||
await ensureConnected();
|
||||
const written = await writeSkuToTag(String(sku), releaseId || null);
|
||||
res.json({ ok: true, sku: written, releaseId: releaseId || null });
|
||||
} catch (err) {
|
||||
logger.event('write_failed', { sku, releaseId, error: err.message });
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/read-tag', (req, res) => {
|
||||
withSerial(async () => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await ensureConnected();
|
||||
const { sku, releaseId, rssi, antenna } = await readSkuFromTag();
|
||||
logger.event('read_ok', { sku, releaseId, rssi, durationMs: Date.now() - startedAt });
|
||||
res.json({ ok: true, sku, releaseId, rssi, antenna });
|
||||
} catch (err) {
|
||||
logger.event('read_failed', { error: err.message, durationMs: Date.now() - startedAt });
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/read-tid', (req, res) => {
|
||||
withSerial(async () => {
|
||||
try {
|
||||
await ensureConnected();
|
||||
await primeRF();
|
||||
const resp = await sendCommand(buildReadFrame(0x02, 0x00, 4), 6000);
|
||||
if (resp.status !== 0x00) return res.status(500).json({ ok: false, error: `TID read status 0x${resp.status.toString(16)}` });
|
||||
const tidBytes = resp.data.slice(resp.data.length - 8);
|
||||
const mdid = tidBytes[1];
|
||||
const chipNames = { 0x80: 'Impinj Monza', 0x00: 'NXP UCODE', 0x82: 'Alien Higgs', 0x03: 'EM Microelectronic' };
|
||||
const chip = chipNames[mdid] || `vendor 0x${mdid.toString(16).padStart(2,'0')}`;
|
||||
res.json({ ok: true, chip, tid: hexDump(tidBytes) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/get-config', (req, res) => {
|
||||
withSerial(async () => {
|
||||
try {
|
||||
await ensureConnected();
|
||||
const resp = await sendCommand(buildFrame(CMD_GET_PARAM, Buffer.alloc(0)), 3000);
|
||||
if (resp.status !== 0x00) return res.status(500).json({ ok: false, error: `GET_ALL_PARAM status 0x${resp.status.toString(16)}` });
|
||||
const d = resp.data;
|
||||
const workModeNames = { 0: 'answer', 1: 'active', 2: 'trigger' };
|
||||
const ifaceNames = { 0x80: 'RS232', 0x40: 'RS485', 0x20: 'RJ45', 0x10: 'WiFi', 0x01: 'USB', 0x02: 'keyboard', 0x04: 'CDC_COM' };
|
||||
const baudNames = { 0: '9600', 1: '19200', 2: '38400', 3: '57600', 4: '115200' };
|
||||
res.json({
|
||||
ok: true, raw: hexDump(d),
|
||||
addr: d[0], rfidPro: d[1],
|
||||
workMode: d[2], workModeName: workModeNames[d[2]] || 'unknown',
|
||||
interface: d[3], interfaceName: ifaceNames[d[3]] || `0x${(d[3]||0).toString(16)}`,
|
||||
baudRate: d[4], baudRateName: baudNames[d[4]] || `idx${d[4]}`,
|
||||
ant: d[5], qValue: d[6], session: d[7], inquiryArea: d[8],
|
||||
acsAddr: d[9], acsDataLen: d[10], filterTime: d[11], triggerTime: d[12],
|
||||
rfidPower: d[13], buzzerTime: d[14], pollingInterval: d[15],
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/set-workmode', (req, res) => {
|
||||
const mode = parseInt(req.body?.mode ?? '', 10);
|
||||
if (isNaN(mode) || mode < 0 || mode > 2) return res.status(400).json({ ok: false, error: 'mode must be 0, 1, or 2' });
|
||||
withSerial(async () => {
|
||||
try {
|
||||
await ensureConnected();
|
||||
const getResp = await sendCommand(buildFrame(CMD_GET_PARAM, Buffer.alloc(0)), 3000);
|
||||
if (getResp.status !== 0x00) return res.status(500).json({ ok: false, error: `GET_ALL_PARAM failed: 0x${getResp.status.toString(16)}` });
|
||||
const params = Buffer.from(getResp.data);
|
||||
params[2] = mode;
|
||||
const setResp = await sendCommand(buildFrame(CMD_SET_PARAM, params), 3000);
|
||||
if (setResp.status !== 0x00) return res.status(500).json({ ok: false, error: `SET_ALL_PARAM failed: 0x${setResp.status.toString(16)}` });
|
||||
res.json({ ok: true, workMode: mode });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/device-info', (req, res) => {
|
||||
withSerial(async () => {
|
||||
try {
|
||||
await ensureConnected();
|
||||
const resp = await sendCommand(buildFrame(CMD_DEVICE_INFO, Buffer.alloc(0)), 3000);
|
||||
if (resp.status !== 0x00) return res.status(500).json({ ok: false, error: `GET_DEVICE_INFO status 0x${resp.status.toString(16)}` });
|
||||
const d = resp.data;
|
||||
res.json({ ok: true, raw: hexDump(d) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/diag-summary', (req, res) => {
|
||||
res.json({ ok: true, summary: logger.summary() });
|
||||
});
|
||||
|
||||
app.post('/shutdown', (req, res) => {
|
||||
res.json({ ok: true, message: 'Daemon shutting down' });
|
||||
console.log(`[rfid:${variant}] shutdown requested — exiting`);
|
||||
setTimeout(() => { logger.close(); process.exit(0); }, 200);
|
||||
});
|
||||
|
||||
// ── Inventory lookup via SSH tunnel (unchanged from index.js) ───────────
|
||||
async function inventoryLookup({ releaseId, dbHost, dbName, dbUser, dbPass, sshKeyPath, sshUser }) {
|
||||
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) => {
|
||||
let settled = false;
|
||||
const done = (fn, val) => { if (!settled) { settled = true; fn(val); } };
|
||||
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 done(reject, new Error(`SSH forward failed: ${err.message}`)); }
|
||||
let conn;
|
||||
try {
|
||||
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, instock, crate_id, slot_number, sold_date FROM wp_rmp_disc_inventory WHERE release_id = ? ORDER BY sku ASC',
|
||||
[parseInt(releaseId, 10)]
|
||||
);
|
||||
await conn.end();
|
||||
ssh.end();
|
||||
done(resolve, rows);
|
||||
} catch (e) {
|
||||
try { if (typeof conn !== 'undefined') await conn.end(); } catch (_) {}
|
||||
ssh.end();
|
||||
done(reject, e);
|
||||
}
|
||||
});
|
||||
});
|
||||
ssh.on('error', (e) => { ssh.end(); done(reject, new Error(`SSH error: ${e.message}`)); });
|
||||
ssh.connect({ host: dbHost || DEFAULT_INVENTORY_HOST, port: 22, username: sshUser || DEFAULT_SSH_USER, privateKey });
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/inventory-lookup', async (req, res) => {
|
||||
const { release_id, dbHost, dbName, dbUser, dbPass, sshKeyPath, sshUser } = req.query;
|
||||
if (!release_id) return res.status(400).json({ error: 'release_id is required' });
|
||||
if (isNaN(parseInt(release_id, 10))) return res.status(400).json({ error: 'release_id must be a number' });
|
||||
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 || DEFAULT_INVENTORY_HOST, dbName, dbUser, dbPass, sshKeyPath: sshKeyPath || '~/.ssh/id_rsa', sshUser: sshUser || DEFAULT_SSH_USER });
|
||||
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,
|
||||
instock: r.instock,
|
||||
crate_id: r.crate_id != null ? String(r.crate_id) : null,
|
||||
slot_number: r.slot_number != null ? String(r.slot_number) : null,
|
||||
sold_date: r.sold_date ? String(r.sold_date) : null
|
||||
}))
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[rfid:${variant}][inventory-lookup]`, e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Keepalive ────────────────────────────────────────────────────────────
|
||||
let keepaliveTimer = null;
|
||||
if (KEEPALIVE_MS > 0) {
|
||||
keepaliveTimer = setInterval(() => {
|
||||
const since = Date.now() - lastTxAt;
|
||||
if (since < KEEPALIVE_MS - 500) return; // recent activity, skip
|
||||
withSerial(async () => {
|
||||
if (!serial?.isOpen) return;
|
||||
try {
|
||||
await sendCommand(buildFrame(CMD_GET_PARAM, Buffer.alloc(0)), 1500);
|
||||
logger.event('keepalive_sent', { ok: true });
|
||||
} catch (err) {
|
||||
logger.event('keepalive_sent', { ok: false, reason: err.message });
|
||||
}
|
||||
});
|
||||
}, KEEPALIVE_MS);
|
||||
keepaliveTimer.unref?.();
|
||||
}
|
||||
|
||||
// ── Graceful shutdown ────────────────────────────────────────────────────
|
||||
function gracefulExit(signal) {
|
||||
console.log(`[rfid:${variant}] ${signal} received — flushing log and exiting`);
|
||||
if (keepaliveTimer) clearInterval(keepaliveTimer);
|
||||
logger.close();
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
}
|
||||
process.on('SIGINT', () => gracefulExit('SIGINT'));
|
||||
process.on('SIGTERM', () => gracefulExit('SIGTERM'));
|
||||
|
||||
app.listen(HTTP_PORT, '0.0.0.0', () => {
|
||||
console.log(`[rfid:${variant}] daemon listening on http://0.0.0.0:${HTTP_PORT}`);
|
||||
if (IS_ULTRA) console.log(`[rfid:${variant}] Running LOCALLY on ${HOSTNAME}.`);
|
||||
else console.log(`[rfid:${variant}] Running REMOTELY on ${HOSTNAME}.`);
|
||||
console.log(`[rfid:${variant}] endpoints: GET /status /inventory /read-tag /read-tid /get-config /device-info /diag-summary POST /write-tag /set-workmode /clear-mask /unlock-tag /recover /shutdown`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { startDaemon };
|
||||
8
rfid-daemon/variants/broadcast.js
Normal file
8
rfid-daemon/variants/broadcast.js
Normal file
@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
// broadcast: address all frames with ADDR=0xFF instead of 0x00. Hypothesis:
|
||||
// the firmware routes broadcast frames through a different code path that's
|
||||
// less likely to wedge. (Cheap control — flips a single byte.)
|
||||
require('./_core').startDaemon({
|
||||
variant: 'broadcast',
|
||||
flags: { broadcast: true },
|
||||
});
|
||||
8
rfid-daemon/variants/control.js
Normal file
8
rfid-daemon/variants/control.js
Normal file
@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
// Control variant: same code path as the others but no feature flags on.
|
||||
// Use this as the apples-to-apples baseline (it has the same logging
|
||||
// instrumentation as the variants, unlike ../index.js which has none).
|
||||
require('./_core').startDaemon({
|
||||
variant: 'control',
|
||||
flags: {},
|
||||
});
|
||||
8
rfid-daemon/variants/keepalive.js
Normal file
8
rfid-daemon/variants/keepalive.js
Normal file
@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
// keepalive: send GET_PARAM every 10s when the link is otherwise idle.
|
||||
// Hypothesis: a quiet UART is what trips the firmware into the wedged state;
|
||||
// keeping the line warm prevents it.
|
||||
require('./_core').startDaemon({
|
||||
variant: 'keepalive',
|
||||
flags: { keepalive: true },
|
||||
});
|
||||
75
rfid-daemon/variants/log.js
Normal file
75
rfid-daemon/variants/log.js
Normal file
@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
// Per-session JSON-lines logger shared across all variants. Writes to
|
||||
// rfid-daemon/diagnostics/run-<variant>-<YYYYMMDD-HHMMSS>.log so we can
|
||||
// compare variants apples-to-apples after a session.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const pad2 = n => n.toString().padStart(2, '0');
|
||||
function tsForFilename(d = new Date()) {
|
||||
return `${d.getFullYear()}${pad2(d.getMonth()+1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function createLogger(variant, diagDir) {
|
||||
fs.mkdirSync(diagDir, { recursive: true });
|
||||
const logPath = path.join(diagDir, `run-${variant}-${tsForFilename()}.log`);
|
||||
const stream = fs.createWriteStream(logPath, { flags: 'a' });
|
||||
|
||||
const counters = {
|
||||
write_ok: 0, write_failed: 0,
|
||||
read_ok: 0, read_failed: 0,
|
||||
inventory_ok: 0, inventory_failed: 0,
|
||||
timeout: 0,
|
||||
keepalive_sent: 0, preflight_run: 0,
|
||||
recover_attempted: 0,
|
||||
lockups: 0,
|
||||
};
|
||||
let lastSuccessAt = null;
|
||||
let inLockupSince = null;
|
||||
const startedAt = Date.now();
|
||||
|
||||
function writeLine(obj) {
|
||||
stream.write(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
|
||||
function event(name, data = {}) {
|
||||
const obj = { ts: new Date().toISOString(), ev: name, ...data };
|
||||
writeLine(obj);
|
||||
|
||||
if (name in counters) counters[name]++;
|
||||
|
||||
const isSuccess = (name === 'write_ok' || name === 'read_ok' || name === 'inventory_ok');
|
||||
if (isSuccess) {
|
||||
lastSuccessAt = Date.now();
|
||||
if (inLockupSince) {
|
||||
writeLine({ ts: obj.ts, ev: 'recovered', afterMs: Date.now() - inLockupSince });
|
||||
inLockupSince = null;
|
||||
}
|
||||
}
|
||||
if (name === 'timeout' && !inLockupSince) {
|
||||
inLockupSince = Date.now();
|
||||
counters.lockups++;
|
||||
writeLine({ ts: obj.ts, ev: 'lockup_detected', sinceLastSuccessMs: lastSuccessAt ? Date.now() - lastSuccessAt : null });
|
||||
}
|
||||
}
|
||||
|
||||
function summary() {
|
||||
return {
|
||||
variant,
|
||||
runtimeMs: Date.now() - startedAt,
|
||||
counters: { ...counters },
|
||||
lastSuccessAgoMs: lastSuccessAt ? Date.now() - lastSuccessAt : null,
|
||||
currentlyLocked: !!inLockupSince,
|
||||
};
|
||||
}
|
||||
|
||||
function close() {
|
||||
writeLine({ ts: new Date().toISOString(), ev: 'end', ...summary() });
|
||||
stream.end();
|
||||
}
|
||||
|
||||
return { event, summary, close, logPath };
|
||||
}
|
||||
|
||||
module.exports = { createLogger };
|
||||
8
rfid-daemon/variants/preflight.js
Normal file
8
rfid-daemon/variants/preflight.js
Normal file
@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
// preflight: before each tag op, send STOP and re-assert workMode=answer if
|
||||
// it has drifted. Hypothesis: workMode silently flips between writes and
|
||||
// re-asserting it is enough to keep operations succeeding.
|
||||
require('./_core').startDaemon({
|
||||
variant: 'preflight',
|
||||
flags: { preflight: true },
|
||||
});
|
||||
8
rfid-daemon/variants/slowpace.js
Normal file
8
rfid-daemon/variants/slowpace.js
Normal file
@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
// slowpace: hold a 500ms gap between consecutive TX frames and pause 2s
|
||||
// after a successful tag write. Hypothesis: timing/duty-cycle pressure on
|
||||
// the K924 module is the trigger; slowing down lets it stay healthy.
|
||||
require('./_core').startDaemon({
|
||||
variant: 'slowpace',
|
||||
flags: { slowpace: true },
|
||||
});
|
||||
815
sheets.js
Normal file
815
sheets.js
Normal file
@ -0,0 +1,815 @@
|
||||
// Cache-busting comment - Updated to fix ES6 syntax issues - v2.0
|
||||
// Functions from utils.js are available globally
|
||||
// import { getServiceAccountToken, getGoogleSettings } from './utils.js';
|
||||
|
||||
// Base64 URL encoding function for JWT tokens
|
||||
function base64UrlEncode(data) {
|
||||
if (typeof data === 'string') {
|
||||
data = new TextEncoder().encode(data);
|
||||
}
|
||||
|
||||
let base64 = btoa(String.fromCharCode(...data));
|
||||
|
||||
// Convert to base64url format
|
||||
return base64.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
async function updateSheet(releaseId, data, range) {
|
||||
const googleSettings = await getGoogleSettings();
|
||||
if (!googleSettings || !googleSettings.spreadsheetId) {
|
||||
throw new Error('Google settings not configured.');
|
||||
}
|
||||
|
||||
const token = await getServiceAccountToken();
|
||||
const searchResponse = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${googleSettings.spreadsheetId}/values/Sheet1!B:B`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const searchData = await searchResponse.json();
|
||||
if (!searchData.values) {
|
||||
throw new Error('Could not find release ID column in spreadsheet.');
|
||||
}
|
||||
const rowIndex = searchData.values.findIndex(row => row[0] === releaseId.toString());
|
||||
|
||||
if (rowIndex === -1) {
|
||||
throw new Error('Release not found in spreadsheet!');
|
||||
}
|
||||
const updateRange = `Sheet1!${range}${rowIndex + 1}`;
|
||||
const updateResponse = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${googleSettings.spreadsheetId}/values/${updateRange}?valueInputOption=USER_ENTERED`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
values: [[data]]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
const errorData = await updateResponse.json();
|
||||
throw new Error(`Failed to update spreadsheet: ${errorData.error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get condition abbreviations
|
||||
function getConditionAbbrev(condition) {
|
||||
const conditionMap = {
|
||||
'Generic (GEN)': 'GEN',
|
||||
'Mint (M)': 'M',
|
||||
'Near Mint (NM)': 'NM',
|
||||
'Near Mint (NM or M-)': 'NM',
|
||||
'Near Mint Minus (NM-)': 'NM-',
|
||||
'Very Good Plus (VG+)': 'VG+',
|
||||
'Very Good (VG)': 'VG',
|
||||
'Very Good Minus (VG-)': 'VG-',
|
||||
'Company Sleeve (C.SL)': 'C.SL',
|
||||
'Good Plus Plus (G++)': 'G++',
|
||||
'Good Plus (G+)': 'G+',
|
||||
'Good (G)': 'G',
|
||||
'Fair (F)': 'F',
|
||||
'Poor (P)': 'P'
|
||||
};
|
||||
return conditionMap[condition] || condition;
|
||||
}
|
||||
|
||||
// Helper function to format track artists
|
||||
function formatTrackArtists(tracklist) {
|
||||
if (!tracklist || !Array.isArray(tracklist)) return '';
|
||||
|
||||
return tracklist.map(track => {
|
||||
let artistsInfo = [];
|
||||
|
||||
// Handle regular artists
|
||||
if (track.artists && track.artists.length > 0) {
|
||||
const artists = track.artists.map(artist => {
|
||||
const id = artist.id ? `[${artist.id}]` : '';
|
||||
return `${artist.name}${id}`;
|
||||
}).join(', ');
|
||||
artistsInfo.push(`${track.position || ''}: ${artists}`);
|
||||
}
|
||||
|
||||
// Handle extraartists (remixers, producers, etc.)
|
||||
if (track.extraartists && track.extraartists.length > 0) {
|
||||
const extraArtists = track.extraartists.map(artist => {
|
||||
const id = artist.id ? `[${artist.id}]` : '';
|
||||
const role = artist.role ? ` - ${artist.role}` : '';
|
||||
return `${track.position || ''}. ${artist.name}${id}${role}`;
|
||||
}).join('; ');
|
||||
artistsInfo.push(extraArtists);
|
||||
}
|
||||
|
||||
return artistsInfo.join(' | ');
|
||||
}).filter(item => item).join(' | ');
|
||||
}
|
||||
|
||||
async function addToSpreadsheet(data, showAlert = false) {
|
||||
// Add debugging to see what data we're receiving
|
||||
// console.log('=== DEBUGGING addToSpreadsheet ===');
|
||||
// console.log('Received data object:', data);
|
||||
// console.log('Data keys:', Object.keys(data));
|
||||
// console.log('data.id:', data.id);
|
||||
// console.log('data.artist:', data.artist);
|
||||
// console.log('data.title:', data.title);
|
||||
// console.log('data.lowPrice:', data.lowPrice);
|
||||
// console.log('data.have:', data.have);
|
||||
// console.log('data.want:', data.want);
|
||||
// console.log('data.tracklist:', data.tracklist);
|
||||
// console.log('data.extraartists:', data.extraartists);
|
||||
// console.log('=== END DEBUGGING ===');
|
||||
|
||||
const googleSettings = await getGoogleSettings();
|
||||
if (!googleSettings || !googleSettings.spreadsheetId) {
|
||||
alert('Google settings not configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
const price = data.price?.trim() || '';
|
||||
const range = price === '$5' ? 'FIVEDOLLAR' : 'Sheet1';
|
||||
|
||||
try {
|
||||
const token = await getServiceAccountToken();
|
||||
|
||||
// Prepare all the data variables
|
||||
const formattedTimestamp = new Date().toISOString();
|
||||
const labelName = data.label || '';
|
||||
const formatText = data.formats ?
|
||||
`${data.format_quantity || 1}x ${data.formats.map(f => `${f.name} ${f.descriptions?.join(', ') || ''}`).join(', ')}`.trim() : '';
|
||||
|
||||
// Get current UI values (fallback to data if not available)
|
||||
const currentMediaCondition = data.mediaCondition || '';
|
||||
const currentSleeveCondition = data.sleeveCondition || '';
|
||||
const currentPrice = data.price || '';
|
||||
|
||||
// Market data
|
||||
const marketData = {
|
||||
lowPrice: data.lowPrice || '',
|
||||
medianPrice: data.medianPrice || '',
|
||||
highPrice: data.highPrice || '',
|
||||
lastSold: data.lastSold || '',
|
||||
labelId: data.labelId || '',
|
||||
imageUrl: data.imageUrl || ''
|
||||
};
|
||||
|
||||
// Process complex data
|
||||
const tracklistData = data.tracklist ?
|
||||
data.tracklist.map(track => `${track.position || ''}: ${track.title || ''}`).join(' | ') : '';
|
||||
|
||||
const extraArtistsData = data.extraartists ?
|
||||
data.extraartists.map(artist => `${artist.name} - ${artist.role}`).join(', ') : '';
|
||||
|
||||
const videosData = data.videos ?
|
||||
data.videos.map(video => video.uri).join(', ') : '';
|
||||
|
||||
const priceSuggestionsString = data.priceSuggestions ?
|
||||
Object.entries(data.priceSuggestions).map(([condition, price]) => `${condition}: ${price}`).join(', ') : '';
|
||||
|
||||
// Get current column mappings (will use defaults if none saved)
|
||||
const mappings = await getCurrentMappings();
|
||||
|
||||
// Build the complete row dynamically based on mappings
|
||||
const row = await buildRowFromMappings(mappings, {
|
||||
formattedTimestamp,
|
||||
data,
|
||||
labelName,
|
||||
formatText,
|
||||
currentMediaCondition,
|
||||
currentSleeveCondition,
|
||||
currentPrice,
|
||||
marketData,
|
||||
tracklistData,
|
||||
extraArtistsData,
|
||||
videosData,
|
||||
priceSuggestionsString
|
||||
});
|
||||
|
||||
const values = [row];
|
||||
|
||||
const response = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${googleSettings.spreadsheetId}/values/${range}:append?valueInputOption=USER_ENTERED`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ values })
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.error.message}`);
|
||||
}
|
||||
|
||||
if (showAlert) {
|
||||
return { success: true, message: 'written to sheet√' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
// console.error('Error adding to spreadsheet:', error);
|
||||
if (showAlert) {
|
||||
return { success: false, message: `Failed: ${error.message}` };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Add updateSheetOnly function from popup.js
|
||||
async function updateSheetOnly(releaseData) {
|
||||
// console.log('=== GOOGLE SHEETS UPDATE STARTED ===');
|
||||
// console.log('Release data received:', releaseData);
|
||||
|
||||
try {
|
||||
const googleSettings = await getGoogleSettings();
|
||||
// console.log('Google settings retrieved:', googleSettings ? 'Present' : 'Missing');
|
||||
|
||||
if (!googleSettings || !googleSettings.spreadsheetId || !googleSettings.clientEmail || !googleSettings.privateKey) {
|
||||
// console.error('Google settings incomplete:', googleSettings);
|
||||
return { success: false, message: 'Google Sheets settings not configured properly' };
|
||||
}
|
||||
|
||||
// console.log('Attempting to update Google Sheet...');
|
||||
const result = await updateGoogleSheet(releaseData, googleSettings);
|
||||
// console.log('Google Sheets update result:', result);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
// console.error('Error in updateSheetOnly:', error);
|
||||
return { success: false, message: `Google Sheets error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGoogleSheet(releaseData, googleSettings) {
|
||||
// console.log('=== UPDATING GOOGLE SHEET ===');
|
||||
// console.log('Spreadsheet ID:', googleSettings.spreadsheetId);
|
||||
// console.log('Client Email:', googleSettings.clientEmail);
|
||||
// console.log('Private Key present:', !!googleSettings.privateKey);
|
||||
|
||||
try {
|
||||
// Get access token using service account
|
||||
const accessToken = await getServiceAccountToken();
|
||||
// console.log('Access token obtained successfully');
|
||||
|
||||
// Use the same comprehensive data mapping as addToSpreadsheet
|
||||
const data = releaseData;
|
||||
|
||||
// Prepare all the data variables (same as addToSpreadsheet)
|
||||
const formattedTimestamp = data._fixedTimestamp || new Date().toISOString();
|
||||
const labelName = data.label || '';
|
||||
const formatText = data.formats ?
|
||||
`${data.format_quantity || 1}x ${data.formats.map(f => `${f.name} ${f.descriptions?.join(', ') || ''}`).join(', ')}`.trim() : '';
|
||||
|
||||
// Get current UI values (fallback to data if not available)
|
||||
const currentMediaCondition = data.mediaCondition || '';
|
||||
const currentSleeveCondition = data.sleeveCondition || '';
|
||||
const currentPrice = data.price || '';
|
||||
|
||||
// Market data
|
||||
const marketData = {
|
||||
lowPrice: data.lowPrice || '',
|
||||
medianPrice: data.medianPrice || '',
|
||||
highPrice: data.highPrice || '',
|
||||
lastSold: data.lastSold || '',
|
||||
labelId: data.labelId || '',
|
||||
imageUrl: data.imageUrl || ''
|
||||
};
|
||||
|
||||
// Process complex data
|
||||
const tracklistData = data.tracklist ?
|
||||
data.tracklist.map(track => `${track.position || ''}: ${track.title || ''}`).join(' | ') : '';
|
||||
|
||||
const extraArtistsData = data.extraartists ?
|
||||
data.extraartists.map(artist => `${artist.name} - ${artist.role}`).join(', ') : '';
|
||||
|
||||
const videosData = data.videos ?
|
||||
data.videos.map(video => video.uri).join(', ') : '';
|
||||
|
||||
const priceSuggestionsString = data.priceSuggestions ?
|
||||
Object.entries(data.priceSuggestions).map(([condition, price]) => `${condition}: ${price}`).join(', ') : '';
|
||||
|
||||
// Build the complete row with all 38 fields (A through AL) matching exact sheet column order
|
||||
const rowData = [
|
||||
formattedTimestamp, // A: TIMESTAMP
|
||||
data.id || '', // B: REL_ID
|
||||
data.artist || '', // C: ARTIST
|
||||
data.title || '', // D: TITLE
|
||||
data.genre || '', // E: GENRE
|
||||
data.style || '', // F: STYLE
|
||||
labelName, // G: LABEL
|
||||
data.year || '', // H: YEAR
|
||||
data.country || '', // I: COUNTRY
|
||||
formatText, // J: DESCRIPTION
|
||||
getConditionAbbrev(currentMediaCondition), // K: MED
|
||||
getConditionAbbrev(currentSleeveCondition), // L: SLV
|
||||
currentPrice, // M: PRICE
|
||||
data.have || '', // N: HAVE
|
||||
data.want || '', // O: WANT
|
||||
data.num_for_sale || '', // P: NUM FOR SALE
|
||||
marketData.lowPrice || '', // Q: LOWEST
|
||||
marketData.medianPrice || '', // R: MEDIAN
|
||||
marketData.highPrice || '', // S: HIGHEST
|
||||
marketData.lastSold || '', // T: LAST SOLD
|
||||
tracklistData, // U: TRACKLIST
|
||||
extraArtistsData, // V: EXTRA ARTISTS
|
||||
videosData, // W: YOUTUBE
|
||||
priceSuggestionsString, // X: SUGGESTED PRICE
|
||||
marketData.labelId || '', // Y: LABEL ID
|
||||
data.artists?.map(a => a.id).join(', ') || '', // Z: ARTIST IDS
|
||||
'', // AA: (empty column)
|
||||
marketData.imageUrl || '', // AB: IMAGE
|
||||
data.comment || '', // AC: COMMENT
|
||||
data.images?.map(img => img.uri).join(', ') || '', // AD: image urls
|
||||
data.appleId || '', // AE: APPLE ID
|
||||
data.labels?.[0]?.catno || '', // AF: CAT NO
|
||||
formatTrackArtists(data.tracklist), // AG: TRACK ARTISTS
|
||||
data.collectionFolder || '', // AH: FOLDER
|
||||
data.notes || '', // AI: NOTES
|
||||
data.companies?.map(c => `${c.name} - ${c.entity_type_name} - ${c.id}`).join(', ') || '', // AJ: companies
|
||||
data.identifiers?.map(c => `${c.type} - ${c.value}`).join(', ') || '', // AK: identifiers
|
||||
data.estimated_weight || '' // AL: estimated wt
|
||||
];
|
||||
|
||||
// console.log('Row data prepared:', rowData);
|
||||
|
||||
// Determine the correct sheet based on price
|
||||
const price = data.price?.trim() || '';
|
||||
const sheetName = price === '$5' ? 'FIVEDOLLAR' : 'Sheet1';
|
||||
|
||||
// Append the data to the sheet
|
||||
const appendUrl = `https://sheets.googleapis.com/v4/spreadsheets/${googleSettings.spreadsheetId}/values/${sheetName}:append?valueInputOption=USER_ENTERED`;
|
||||
|
||||
const response = await fetch(appendUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
values: [rowData]
|
||||
})
|
||||
});
|
||||
|
||||
// console.log('Google Sheets API response status:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
// console.log('Google Sheets update successful:', result);
|
||||
return { success: true, message: 'Data successfully added to Google Sheets!' };
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
// console.error('Google Sheets API error:', response.status, errorText);
|
||||
return { success: false, message: `Google Sheets API error: ${response.status}` };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// console.error('Error updating Google Sheet:', error);
|
||||
return { success: false, message: `Error updating Google Sheet: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function createJWT(clientEmail, privateKey, scope) {
|
||||
// console.log('Creating JWT token...');
|
||||
|
||||
const header = {
|
||||
alg: 'RS256',
|
||||
typ: 'JWT'
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iss: clientEmail,
|
||||
scope: scope || 'https://www.googleapis.com/auth/spreadsheets',
|
||||
aud: 'https://oauth2.googleapis.com/token',
|
||||
exp: now + 3600,
|
||||
iat: now
|
||||
};
|
||||
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
|
||||
const signatureInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
// Import the private key
|
||||
const keyData = privateKey.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||
.replace(/-----END PRIVATE KEY-----/, '')
|
||||
.replace(/\s/g, '');
|
||||
|
||||
const binaryKey = Uint8Array.from(atob(keyData), c => c.charCodeAt(0));
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
binaryKey,
|
||||
{
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
'RSASSA-PKCS1-v1_5',
|
||||
cryptoKey,
|
||||
new TextEncoder().encode(signatureInput)
|
||||
);
|
||||
|
||||
const encodedSignature = base64UrlEncode(new Uint8Array(signature));
|
||||
|
||||
const jwt = `${signatureInput}.${encodedSignature}`;
|
||||
// console.log('JWT token created successfully');
|
||||
return jwt;
|
||||
}
|
||||
|
||||
async function getAccessToken(jwtToken) {
|
||||
// console.log('Getting access token...');
|
||||
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwtToken}`
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// console.log('Access token obtained successfully');
|
||||
return data.access_token;
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
// console.error('Error getting access token:', response.status, errorText);
|
||||
throw new Error(`Failed to get access token: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to detect sheet column structure and headers
|
||||
async function detectSheetStructure(spreadsheetId, sheetName = 'Sheet1') {
|
||||
try {
|
||||
const accessToken = await getServiceAccountToken();
|
||||
|
||||
// First, get the sheet properties to find the actual column count
|
||||
const sheetPropsUrl = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?fields=sheets.properties`;
|
||||
const propsResponse = await fetch(sheetPropsUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!propsResponse.ok) {
|
||||
throw new Error(`Failed to get sheet properties: ${propsResponse.status}`);
|
||||
}
|
||||
|
||||
const propsData = await propsResponse.json();
|
||||
const sheet = propsData.sheets.find(s => s.properties.title === sheetName) || propsData.sheets[0];
|
||||
const columnCount = sheet.properties.gridProperties.columnCount;
|
||||
|
||||
// Convert column count to letter range (e.g., 37 columns = A:AK)
|
||||
const endColumn = getColumnLetter(columnCount);
|
||||
const headerRange = `${sheetName}!A1:${endColumn}1`;
|
||||
|
||||
// Get the header row
|
||||
const headerUrl = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(headerRange)}`;
|
||||
const headerResponse = await fetch(headerUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!headerResponse.ok) {
|
||||
throw new Error(`Failed to get sheet headers: ${headerResponse.status}`);
|
||||
}
|
||||
|
||||
const headerData = await headerResponse.json();
|
||||
const headers = headerData.values ? headerData.values[0] : [];
|
||||
|
||||
// Build column structure
|
||||
const columns = [];
|
||||
for (let i = 0; i < columnCount; i++) {
|
||||
const columnLetter = getColumnLetter(i + 1);
|
||||
const headerText = headers[i] || '';
|
||||
columns.push({
|
||||
letter: columnLetter,
|
||||
header: headerText,
|
||||
index: i
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
columnCount,
|
||||
endColumn,
|
||||
columns,
|
||||
sheetName: sheet.properties.title
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error detecting sheet structure:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert column index to letter (1=A, 2=B, etc.)
|
||||
function getColumnLetter(columnIndex) {
|
||||
let result = '';
|
||||
while (columnIndex > 0) {
|
||||
columnIndex--;
|
||||
result = String.fromCharCode(65 + (columnIndex % 26)) + result;
|
||||
columnIndex = Math.floor(columnIndex / 26);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Function to auto-detect field mappings based on header text
|
||||
function detectFieldMappings(columns) {
|
||||
const mappings = {};
|
||||
const fieldPatterns = {
|
||||
'timestamp': /^(timestamp|time|date)$/i,
|
||||
'rel_id': /^(rel_id|release_id|id|discogs_id)$/i,
|
||||
'artist': /^(artist|artists)$/i,
|
||||
'title': /^(title|album|release)$/i,
|
||||
'genre': /^(genre|genres)$/i,
|
||||
'style': /^(style|styles)$/i,
|
||||
'label': /^(label|labels)$/i,
|
||||
'year': /^(year|released|date)$/i,
|
||||
'country': /^(country|nation)$/i,
|
||||
'description': /^(description|format|desc)$/i,
|
||||
'med': /^(med|media|condition|media_condition)$/i,
|
||||
'slv': /^(slv|sleeve|sleeve_condition)$/i,
|
||||
'price': /^(price|cost|amount)$/i,
|
||||
'have': /^(have|quantity|qty)$/i,
|
||||
'want': /^(want|wanted)$/i,
|
||||
'num_for_sale': /^(num_for_sale|for_sale|available)$/i,
|
||||
'lowest': /^(lowest|low|min_price)$/i,
|
||||
'median': /^(median|med_price|average)$/i,
|
||||
'highest': /^(highest|high|max_price)$/i,
|
||||
'last_sold': /^(last_sold|sold|recent)$/i,
|
||||
'tracklist': /^(tracklist|tracks|track_list)$/i,
|
||||
'extra_artists': /^(extra_artists|featuring|feat)$/i,
|
||||
'youtube': /^(youtube|video|videos)$/i,
|
||||
'suggested_price': /^(suggested_price|suggestion|recommended)$/i,
|
||||
'label_id': /^(label_id|lbl_id)$/i,
|
||||
'artist_ids': /^(artist_ids|artist_id)$/i,
|
||||
'image': /^(image|img|picture|photo)$/i,
|
||||
'comment': /^(comment|comments|notes)$/i,
|
||||
'image_urls': /^(image_urls|images|img_urls)$/i,
|
||||
'apple_id': /^(apple_id|itunes|apple)$/i,
|
||||
'cat_no': /^(cat_no|catalog|catalogue|catno)$/i,
|
||||
'track_artists': /^(track_artists|track_artist)$/i,
|
||||
'folder': /^(folder|collection|coll)$/i,
|
||||
'notes': /^(notes|note|remarks)$/i,
|
||||
'companies': /^(companies|company|labels)$/i,
|
||||
'identifiers': /^(identifiers|barcode|upc)$/i,
|
||||
'estimated_wt': /^(estimated_wt|weight|wt)$/i
|
||||
};
|
||||
|
||||
columns.forEach(column => {
|
||||
const header = column.header.trim();
|
||||
let matchedField = 'skip';
|
||||
|
||||
// Try to match header text to field patterns
|
||||
for (const [fieldType, pattern] of Object.entries(fieldPatterns)) {
|
||||
if (pattern.test(header)) {
|
||||
matchedField = fieldType;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
mappings[column.letter] = matchedField;
|
||||
});
|
||||
|
||||
return mappings;
|
||||
}
|
||||
|
||||
// Helper function to get current mappings (from popup.js)
|
||||
async function getCurrentMappings() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(['columnMappings', 'detectedStructure'], (result) => {
|
||||
if (result.columnMappings) {
|
||||
resolve({
|
||||
mappings: result.columnMappings,
|
||||
structure: result.detectedStructure || null
|
||||
});
|
||||
} else {
|
||||
// Return default mappings if none saved
|
||||
resolve({
|
||||
mappings: getDefaultMappings(),
|
||||
structure: null
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Default mappings matching the original hardcoded structure
|
||||
function getDefaultMappings() {
|
||||
return {
|
||||
'A': 'timestamp',
|
||||
'B': 'rel_id',
|
||||
'C': 'artist',
|
||||
'D': 'title',
|
||||
'E': 'genre',
|
||||
'F': 'style',
|
||||
'G': 'label',
|
||||
'H': 'year',
|
||||
'I': 'country',
|
||||
'J': 'description',
|
||||
'K': 'med',
|
||||
'L': 'slv',
|
||||
'M': 'price',
|
||||
'N': 'have',
|
||||
'O': 'want',
|
||||
'P': 'num_for_sale',
|
||||
'Q': 'lowest',
|
||||
'R': 'median',
|
||||
'S': 'highest',
|
||||
'T': 'last_sold',
|
||||
'U': 'tracklist',
|
||||
'V': 'extra_artists',
|
||||
'W': 'youtube',
|
||||
'X': 'suggested_price',
|
||||
'Y': 'label_id',
|
||||
'Z': 'artist_ids',
|
||||
'AA': 'skip',
|
||||
'AB': 'image',
|
||||
'AC': 'comment',
|
||||
'AD': 'image_urls',
|
||||
'AE': 'apple_id',
|
||||
'AF': 'cat_no',
|
||||
'AG': 'track_artists',
|
||||
'AH': 'folder',
|
||||
'AI': 'notes',
|
||||
'AJ': 'companies',
|
||||
'AK': 'identifiers',
|
||||
'AL': 'estimated_wt'
|
||||
};
|
||||
}
|
||||
|
||||
// Build row based on dynamic mappings
|
||||
async function buildRowFromMappings(mappingsData, dataContext) {
|
||||
const { mappings, structure } = mappingsData;
|
||||
const {
|
||||
formattedTimestamp,
|
||||
data,
|
||||
labelName,
|
||||
formatText,
|
||||
currentMediaCondition,
|
||||
currentSleeveCondition,
|
||||
currentPrice,
|
||||
marketData,
|
||||
tracklistData,
|
||||
extraArtistsData,
|
||||
videosData,
|
||||
priceSuggestionsString
|
||||
} = dataContext;
|
||||
|
||||
const row = [];
|
||||
|
||||
// Use detected structure columns if available, otherwise default A-AL
|
||||
const columns = structure ?
|
||||
structure.columns.map(c => c.letter) :
|
||||
['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL'];
|
||||
|
||||
for (const column of columns) {
|
||||
const fieldType = mappings[column] || 'skip';
|
||||
let value = '';
|
||||
|
||||
switch (fieldType) {
|
||||
case 'timestamp':
|
||||
value = formattedTimestamp;
|
||||
break;
|
||||
case 'rel_id':
|
||||
value = data.id || '';
|
||||
break;
|
||||
case 'artist':
|
||||
value = data.artist || '';
|
||||
break;
|
||||
case 'title':
|
||||
value = data.title || '';
|
||||
break;
|
||||
case 'genre':
|
||||
value = data.genre || '';
|
||||
break;
|
||||
case 'style':
|
||||
value = data.style || '';
|
||||
break;
|
||||
case 'label':
|
||||
value = labelName;
|
||||
break;
|
||||
case 'year':
|
||||
value = data.year || '';
|
||||
break;
|
||||
case 'country':
|
||||
value = data.country || '';
|
||||
break;
|
||||
case 'description':
|
||||
value = formatText;
|
||||
break;
|
||||
case 'med':
|
||||
value = getConditionAbbrev(currentMediaCondition);
|
||||
break;
|
||||
case 'slv':
|
||||
value = getConditionAbbrev(currentSleeveCondition);
|
||||
break;
|
||||
case 'price':
|
||||
value = currentPrice;
|
||||
break;
|
||||
case 'have':
|
||||
value = data.have || '';
|
||||
break;
|
||||
case 'want':
|
||||
value = data.want || '';
|
||||
break;
|
||||
case 'num_for_sale':
|
||||
value = data.num_for_sale || '';
|
||||
break;
|
||||
case 'lowest':
|
||||
value = marketData.lowPrice || '';
|
||||
break;
|
||||
case 'median':
|
||||
value = marketData.medianPrice || '';
|
||||
break;
|
||||
case 'highest':
|
||||
value = marketData.highPrice || '';
|
||||
break;
|
||||
case 'last_sold':
|
||||
value = marketData.lastSold || '';
|
||||
break;
|
||||
case 'tracklist':
|
||||
value = tracklistData;
|
||||
break;
|
||||
case 'extra_artists':
|
||||
value = extraArtistsData;
|
||||
break;
|
||||
case 'youtube':
|
||||
value = videosData;
|
||||
break;
|
||||
case 'suggested_price':
|
||||
value = priceSuggestionsString;
|
||||
break;
|
||||
case 'label_id':
|
||||
value = marketData.labelId || '';
|
||||
break;
|
||||
case 'artist_ids':
|
||||
value = data.artists?.map(a => a.id).join(', ') || '';
|
||||
break;
|
||||
case 'image':
|
||||
value = marketData.imageUrl || '';
|
||||
break;
|
||||
case 'comment':
|
||||
value = data.comment || '';
|
||||
break;
|
||||
case 'image_urls':
|
||||
value = data.images?.map(img => img.uri).join(', ') || '';
|
||||
break;
|
||||
case 'apple_id':
|
||||
value = data.appleId || '';
|
||||
break;
|
||||
case 'cat_no':
|
||||
value = data.labels?.[0]?.catno || '';
|
||||
break;
|
||||
case 'track_artists':
|
||||
value = formatTrackArtists(data.tracklist);
|
||||
break;
|
||||
case 'folder':
|
||||
value = data.collectionFolder || '';
|
||||
break;
|
||||
case 'notes':
|
||||
value = data.notes || '';
|
||||
break;
|
||||
case 'companies':
|
||||
value = data.companies?.map(c => `${c.name} - ${c.entity_type_name} - ${c.id}`).join(', ') || '';
|
||||
break;
|
||||
case 'identifiers':
|
||||
value = data.identifiers?.map(c => `${c.type} - ${c.value}`).join(', ') || '';
|
||||
break;
|
||||
case 'estimated_wt':
|
||||
value = data.estimated_weight || '';
|
||||
break;
|
||||
case 'actual_weight':
|
||||
case 'actualWeight':
|
||||
value = data.actual_weight != null ? data.actual_weight : '';
|
||||
break;
|
||||
case 'skip':
|
||||
default:
|
||||
value = '';
|
||||
break;
|
||||
}
|
||||
|
||||
row.push(value);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
179
small.css
Normal file
179
small.css
Normal file
@ -0,0 +1,179 @@
|
||||
.print-label {
|
||||
font-family: "MS Gothic", "Hiragino Kaku Gothic Pro", Arial, sans-serif;
|
||||
width: 49mm !important;
|
||||
height: 19mm;
|
||||
position: relative;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
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;
|
||||
zoom: 1;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: 51mm 19mm;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
@-webkit-keyframes removeMargins {
|
||||
from {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
to {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes removeMargins {
|
||||
from {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
to {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 51mm;
|
||||
height: 19mm;
|
||||
min-height: 19mm;
|
||||
max-height: 19mm;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.print-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 51mm !important;
|
||||
height: 19mm !important;
|
||||
min-height: 19mm !important;
|
||||
max-height: 19mm !important;
|
||||
-webkit-animation: removeMargins 1ms;
|
||||
animation: removeMargins 1ms;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media print {
|
||||
.print-label {
|
||||
border: 0mm solid black !important;
|
||||
}
|
||||
}
|
||||
|
||||
.print-artist {
|
||||
left: 2mm;
|
||||
top: 0mm;
|
||||
width: 37mm;
|
||||
height: 3.5mm;
|
||||
font-size: 7pt;
|
||||
}
|
||||
|
||||
.print-title {
|
||||
left: 2mm;
|
||||
top: 3.5mm;
|
||||
width: 37mm;
|
||||
height: 3.5mm;
|
||||
font-size: 7pt;
|
||||
}
|
||||
|
||||
.print-genre {
|
||||
left: 2mm;
|
||||
top: 7mm;
|
||||
width: 37mm;
|
||||
height: 3.5mm;
|
||||
font-size: 7pt;
|
||||
}
|
||||
|
||||
.print-style {
|
||||
left: 2mm;
|
||||
top: 10.5mm;
|
||||
width: 37mm;
|
||||
height: 3.5mm;
|
||||
font-size: 8pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.print-description {
|
||||
left: 2mm;
|
||||
top: 14mm;
|
||||
width: 37mm;
|
||||
height: 2.5mm;
|
||||
font-size: 5pt;
|
||||
}
|
||||
|
||||
.print-bottom-info {
|
||||
left: 2mm;
|
||||
top: 16.5mm;
|
||||
width: 37mm;
|
||||
height: 2.5mm;
|
||||
font-size: 5pt;
|
||||
}
|
||||
|
||||
.print-logo {
|
||||
position: absolute;
|
||||
right: 0.5mm;
|
||||
top: 0mm;
|
||||
width: 10mm;
|
||||
/* Increased to account for full width needed */
|
||||
height: 2mm;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.print-price {
|
||||
right: 0.5mm;
|
||||
top: 2mm;
|
||||
width: 12.5mm;
|
||||
height: 4mm;
|
||||
font-size: 10pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.print-condition {
|
||||
right: 0.5mm;
|
||||
top: 5.8mm;
|
||||
width: 10mm;
|
||||
height: 2.5mm;
|
||||
font-size: 5.5pt;
|
||||
}
|
||||
|
||||
.print-barcode {
|
||||
right: 0.5mm;
|
||||
top: 8.5mm;
|
||||
width: 10mm;
|
||||
height: 10mm;
|
||||
}
|
||||
|
||||
/* Comment and Notes — no room on small label by default; adjust to fit if needed */
|
||||
.print-comment {
|
||||
left: 2mm;
|
||||
top: 19mm;
|
||||
width: 37mm;
|
||||
height: 1.5mm;
|
||||
font-size: 4.5pt;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.print-notes {
|
||||
left: 2mm;
|
||||
top: 20.5mm;
|
||||
width: 37mm;
|
||||
height: 1.5mm;
|
||||
font-size: 4pt;
|
||||
overflow: hidden;
|
||||
}
|
||||
379
textlabel.css
Normal file
379
textlabel.css
Normal file
@ -0,0 +1,379 @@
|
||||
/* Text Label Tab Styles */
|
||||
|
||||
.textlabel-container {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.textlabel-header {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.textlabel-header h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.textlabel-header p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#current-label-size {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
/* Formatting Toolbar */
|
||||
.formatting-toolbar {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbar-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-group label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#text-font-family {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
#text-font-size {
|
||||
width: 60px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.format-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid #ccc;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.format-button:hover {
|
||||
background: #e0e0e0;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.format-button.active {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border-color: #4CAF50;
|
||||
}
|
||||
|
||||
/* Workspace - Input and Preview side by side */
|
||||
.textlabel-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-section,
|
||||
.preview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.input-section label,
|
||||
.preview-section label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Text Input Area */
|
||||
#text-input-area {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
padding: 12px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-family: Arial;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#text-input-area:focus {
|
||||
border-color: #4CAF50;
|
||||
}
|
||||
|
||||
/* Label Preview Box - Fixed Size */
|
||||
.label-preview-box {
|
||||
/* Large label: 25mm x 54mm */
|
||||
width: 54mm;
|
||||
height: 25mm;
|
||||
border: 2px solid #4CAF50;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.label-preview-box.small {
|
||||
/* Small label: 19mm x 51mm */
|
||||
width: 51mm;
|
||||
height: 19mm;
|
||||
}
|
||||
|
||||
.label-preview-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 2mm;
|
||||
box-sizing: border-box;
|
||||
font-family: Arial;
|
||||
font-size: 12pt;
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.label-preview-content.no-wrap {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.label-preview-content.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.label-preview-content.underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Horizontal alignment */
|
||||
.label-preview-content.align-left {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.label-preview-content.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label-preview-content.align-right {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* Vertical alignment */
|
||||
.label-preview-content.valign-top {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.label-preview-content.valign-middle {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.label-preview-content.valign-bottom {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.preview-note {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.textlabel-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.textlabel-actions .action-button {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Status */
|
||||
#text-label-status {
|
||||
text-align: center;
|
||||
min-height: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.textlabel-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.label-preview-box {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles for text labels */
|
||||
@media print {
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#text-label-print-area,
|
||||
#text-label-print-area * {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
#text-label-print-area {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* QR Code Tab Styles */
|
||||
.qrcode-container {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.qrcode-header {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qrcode-header h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.qrcode-header p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.qrcode-input-section {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qrcode-input-section label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#qrcode-integer-input {
|
||||
padding: 10px 15px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
width: 200px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#qrcode-integer-input:focus {
|
||||
border-color: #4CAF50;
|
||||
}
|
||||
|
||||
.qrcode-preview-section {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qrcode-preview-section label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* QR Code Preview Box - Large label size */
|
||||
.qrcode-preview-box {
|
||||
width: 54mm;
|
||||
height: 25mm;
|
||||
border: 2px solid #4CAF50;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qrcode-preview-box img {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.qrcode-preview-section .preview-note {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.qrcode-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.qrcode-actions .action-button {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
#qrcode-status {
|
||||
text-align: center;
|
||||
min-height: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
444
ui.js
Normal file
444
ui.js
Normal file
@ -0,0 +1,444 @@
|
||||
function autoFitText(element, text, maxFontSize = 10, minFontSize = 3.5, isMultiline = false, useEllipsis = false) {
|
||||
if (!element) return;
|
||||
element.textContent = text;
|
||||
// Add flex styling for consistent single-line alignment as in original script
|
||||
if (!isMultiline) {
|
||||
element.style.display = 'flex';
|
||||
element.style.alignItems = 'center';
|
||||
element.style.lineHeight = '1';
|
||||
element.style.whiteSpace = 'nowrap';
|
||||
} else {
|
||||
element.style.whiteSpace = 'normal';
|
||||
element.style.wordBreak = 'break-word';
|
||||
}
|
||||
|
||||
element.style.fontSize = maxFontSize + 'pt';
|
||||
element.style.overflow = 'hidden';
|
||||
|
||||
if (useEllipsis) {
|
||||
element.style.textOverflow = 'ellipsis';
|
||||
element.classList.add('dynamic-text');
|
||||
return;
|
||||
}
|
||||
|
||||
element.style.textOverflow = 'clip';
|
||||
|
||||
// Temporarily force flex-start so scrollWidth measures properly (bypasses right-align overflow bugs)
|
||||
const originalJustify = element.style.justifyContent;
|
||||
if (!isMultiline) {
|
||||
element.style.justifyContent = 'flex-start';
|
||||
}
|
||||
|
||||
let fontSize = maxFontSize;
|
||||
while (fontSize >= minFontSize) {
|
||||
if (element.scrollWidth <= element.clientWidth + 1 && element.scrollHeight <= element.clientHeight + 1) {
|
||||
break;
|
||||
}
|
||||
fontSize -= 0.25;
|
||||
element.style.fontSize = fontSize + 'pt';
|
||||
}
|
||||
|
||||
// Restore original justification
|
||||
if (!isMultiline) {
|
||||
element.style.justifyContent = originalJustify;
|
||||
}
|
||||
|
||||
element.classList.add('dynamic-text');
|
||||
}
|
||||
|
||||
function updatePreview(data) {
|
||||
if (!data) return;
|
||||
|
||||
// Apply label font settings before measuring (affects autoFitText width calculations)
|
||||
const labelFont = document.getElementById('label-font-select')?.value || 'Arial';
|
||||
const labelBold = document.getElementById('label-bold-toggle')?.checked ? 'bold' : 'normal';
|
||||
const labelItalic = document.getElementById('label-italic-toggle')?.checked ? 'italic' : 'normal';
|
||||
['.label-preview', '.label-preview-large', '.label-preview-xlarge'].forEach(sel => {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) { el.style.fontFamily = labelFont; el.style.fontWeight = labelBold; el.style.fontStyle = labelItalic; }
|
||||
});
|
||||
|
||||
// Un-hide parent containers to allow element dimensions to be calculated correctly
|
||||
const hiddenParents = [];
|
||||
const elementsToCheck = [document.querySelector('.label-preview'), document.querySelector('.label-preview-large'), document.querySelector('.label-preview-xlarge')];
|
||||
|
||||
elementsToCheck.forEach(el => {
|
||||
let current = el;
|
||||
while (current && current !== document.body) {
|
||||
const style = window.getComputedStyle(current);
|
||||
if (style.display === 'none') {
|
||||
hiddenParents.push({
|
||||
el: current,
|
||||
display: current.style.display,
|
||||
visibility: current.style.visibility,
|
||||
position: current.style.position
|
||||
});
|
||||
current.style.display = 'block';
|
||||
current.style.visibility = 'hidden';
|
||||
current.style.position = 'absolute';
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
});
|
||||
|
||||
// Per-field style overrides from settings
|
||||
const lfs = window.labelFieldStyles || {};
|
||||
const applyFieldStyle = (selector, fieldKey) => {
|
||||
const s = lfs[fieldKey] || {};
|
||||
[selector, selector + '-large', selector + '-xlarge'].forEach(sel => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return;
|
||||
if (s.b) el.style.fontWeight = 'bold';
|
||||
else el.style.fontWeight = '';
|
||||
if (s.i) el.style.fontStyle = 'italic';
|
||||
else el.style.fontStyle = '';
|
||||
});
|
||||
};
|
||||
['artist','title','genre','style','format','price','condition','comment','notes'].forEach(f => applyFieldStyle('.print-' + f, f));
|
||||
applyFieldStyle('.print-bottom-info', 'info');
|
||||
|
||||
const updateAllSizes = (selector, text, maxFontSize = 10, minFontSize = 3.5, isMultiline = false, fieldKey = null) => {
|
||||
const elSmall = document.querySelector(selector);
|
||||
const elLarge = document.querySelector(selector + '-large');
|
||||
const elXLarge = document.querySelector(selector + '-xlarge');
|
||||
const useEllipsis = fieldKey ? !!(lfs[fieldKey] && lfs[fieldKey].e) : false;
|
||||
|
||||
autoFitText(elSmall, text, maxFontSize, minFontSize, isMultiline, useEllipsis);
|
||||
autoFitText(elLarge, text, maxFontSize + 1.5, minFontSize, isMultiline, useEllipsis);
|
||||
autoFitText(elXLarge, text, maxFontSize + 3, minFontSize, isMultiline, useEllipsis);
|
||||
};
|
||||
|
||||
updateAllSizes('.print-artist', data.artist || '', 8.5, 3.5, false, 'artist');
|
||||
updateAllSizes('.print-title', data.title || '', 8.5, 3.5, false, 'title');
|
||||
updateAllSizes('.print-genre', data.genre || '', 8.5, 3.5, false, 'genre');
|
||||
|
||||
// Shorten 'Progressive' to 'Prog' for label preview
|
||||
const styleText = (data.style || '').replace(/Progressive/g, 'Prog');
|
||||
updateAllSizes('.print-style', styleText, 8.5, 3.5, false, 'style');
|
||||
|
||||
let formatText;
|
||||
if (typeof data.format === 'string' && data.format.trim()) {
|
||||
formatText = data.format;
|
||||
} else {
|
||||
const format = (data.formats || [])[0] || {};
|
||||
const formatDesc = format.descriptions ? format.descriptions.join(' ') : '';
|
||||
formatText = `${data.format_quantity || 1}x ${format.name || ''} ${formatDesc}`.trim();
|
||||
}
|
||||
// Use smaller base for size logic: description uses 6pt / 8pt max usually
|
||||
updateAllSizes('.print-description', formatText, 6.5, 3.5, true, 'format');
|
||||
|
||||
updateAllSizes('.print-comment', data.comment || '', 7, 3.5, false, 'comment');
|
||||
updateAllSizes('.print-notes', data.notes || '', 6, 3.5, false, 'notes');
|
||||
|
||||
['', '-large', '-xlarge'].forEach(suffix => {
|
||||
const bottomInfoDiv = document.querySelector(`.print-bottom-info${suffix}`);
|
||||
if (bottomInfoDiv) {
|
||||
const yearElement = bottomInfoDiv.querySelector('.year');
|
||||
const countryElement = bottomInfoDiv.querySelector('.country');
|
||||
const labelElement = bottomInfoDiv.querySelector('.label');
|
||||
|
||||
const hasYear = data.year && data.year !== 0;
|
||||
|
||||
// Toggle no-year class to hide year span and its separator
|
||||
bottomInfoDiv.classList.toggle('no-year', !hasYear);
|
||||
|
||||
if (yearElement) {
|
||||
yearElement.textContent = hasYear ? data.year.toString() : '';
|
||||
}
|
||||
|
||||
if (countryElement && labelElement) {
|
||||
const countryText = data.country || '';
|
||||
// Remove ' Records' from label text for print preview only
|
||||
const labelText = (data.label || '').replace(/ Records$/, '');
|
||||
|
||||
countryElement.textContent = countryText;
|
||||
labelElement.textContent = labelText;
|
||||
|
||||
// Dynamically fit the entire row by shrinking only the label
|
||||
bottomInfoDiv.style.whiteSpace = 'nowrap';
|
||||
bottomInfoDiv.style.overflow = 'hidden';
|
||||
|
||||
let maxFont = suffix === '-xlarge' ? 9 : (suffix ? 8 : 7);
|
||||
let labelFontSize = maxFont;
|
||||
|
||||
countryElement.style.fontSize = maxFont + 'pt';
|
||||
labelElement.style.fontSize = labelFontSize + 'pt';
|
||||
|
||||
while (labelFontSize >= 3) {
|
||||
if (bottomInfoDiv.scrollWidth <= bottomInfoDiv.clientWidth + 1) {
|
||||
break;
|
||||
}
|
||||
labelFontSize -= 0.25;
|
||||
labelElement.style.fontSize = labelFontSize + 'pt';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
['', '-large', '-xlarge'].forEach(suffix => {
|
||||
const conditionElement = document.querySelector(`.print-condition${suffix}`);
|
||||
if (conditionElement) {
|
||||
const mediaMatch = data.mediaCondition?.match(/\((.*?)\)/);
|
||||
const sleeveMatch = data.sleeveCondition?.match(/\((.*?)\)/);
|
||||
const mediaCondition = mediaMatch ? mediaMatch[1] : data.mediaCondition;
|
||||
const sleeveCondition = sleeveMatch ? sleeveMatch[1] : data.sleeveCondition;
|
||||
const condText = `${mediaCondition} | ${sleeveCondition}`;
|
||||
const condS = lfs['condition'] || {};
|
||||
if (condS.b) conditionElement.style.fontWeight = 'bold'; else conditionElement.style.fontWeight = '';
|
||||
if (condS.i) conditionElement.style.fontStyle = 'italic'; else conditionElement.style.fontStyle = '';
|
||||
autoFitText(conditionElement, condText, suffix === '-xlarge' ? 8.5 : (suffix ? 7.5 : 6), 3.5, false, !!condS.e);
|
||||
}
|
||||
});
|
||||
|
||||
['', '-large', '-xlarge'].forEach(suffix => {
|
||||
const priceElement = document.querySelector(`.print-price${suffix}`);
|
||||
if (priceElement && data.price) {
|
||||
const priceText = String(data.price).startsWith('$') ? data.price : `$${data.price}`;
|
||||
const priceS = lfs['price'] || {};
|
||||
if (priceS.b) priceElement.style.fontWeight = 'bold'; else priceElement.style.fontWeight = '';
|
||||
if (priceS.i) priceElement.style.fontStyle = 'italic'; else priceElement.style.fontStyle = '';
|
||||
autoFitText(priceElement, priceText, suffix === '-xlarge' ? 15 : (suffix ? 13 : 10), 5, false, !!priceS.e);
|
||||
}
|
||||
});
|
||||
|
||||
['', '-large', '-xlarge'].forEach(suffix => {
|
||||
const barcodeElement = document.querySelector(`.print-barcode${suffix}`);
|
||||
if (barcodeElement) {
|
||||
let qrData = data.sku != null ? data.sku : data.id;
|
||||
// Labels always show the bare integer SKU — strip -R and -A suffixes unconditionally
|
||||
if (typeof qrData === 'string') qrData = qrData.replace(/-[AR]$/i, '');
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=100x100&data=${qrData}`;
|
||||
barcodeElement.innerHTML = `<img src="${qrUrl}" alt="${qrData}">`;
|
||||
}
|
||||
});
|
||||
|
||||
// Restore hidden parents
|
||||
for (let i = hiddenParents.length - 1; i >= 0; i--) {
|
||||
const p = hiddenParents[i];
|
||||
p.el.style.display = p.display;
|
||||
p.el.style.visibility = p.visibility;
|
||||
p.el.style.position = p.position;
|
||||
}
|
||||
}
|
||||
|
||||
function displayReleaseInfo(data) {
|
||||
const releaseDetailsContainer = document.getElementById('releaseDetails');
|
||||
if (!releaseDetailsContainer) return;
|
||||
|
||||
const fields = [
|
||||
{ key: 'artist', value: data.artist },
|
||||
{ key: 'title', value: data.title },
|
||||
{ key: 'genre', value: data.genre },
|
||||
{ key: 'style', value: data.style },
|
||||
{ key: 'label', value: (data.label || '').replace(/ Records$/, '') },
|
||||
{ key: 'year', value: data.year === 0 ? '' : data.year },
|
||||
{ key: 'country', value: data.country || 'N/A' },
|
||||
{ key: 'format', value: `${data.format_quantity || 1} x ${data.formats.map(f => `${f.name} (${f.descriptions?.join(', ') || ''})`).join(', ')}` },
|
||||
{ key: 'catno', value: data.labels ? data.labels[0].catno : 'N/A' },
|
||||
{ key: 'releaseId', value: data.id },
|
||||
{ key: 'comment', value: data.comment },
|
||||
{ key: 'appleId', value: data.appleId || 'N/A' },
|
||||
{ key: 'collectionSku', value: data.collectionSku || '' },
|
||||
];
|
||||
|
||||
fields.forEach(field => {
|
||||
const fieldElement = releaseDetailsContainer.querySelector(`.info-field[data-field="${field.key}"] .value`);
|
||||
if (fieldElement) {
|
||||
fieldElement.textContent = field.value;
|
||||
}
|
||||
});
|
||||
|
||||
const marketInfoContainer = document.querySelector('.market-info');
|
||||
if (marketInfoContainer) {
|
||||
marketInfoContainer.innerHTML = `
|
||||
<h3><b>Market Information:</b></h3>
|
||||
<div class="data-row"><span class="data-label"><b>Have:</b></span> ${data.community?.have || 0}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Want:</b></span> ${data.community?.want || 0}</div>
|
||||
<div class="data-row"><span class="data-label"><b>For Sale:</b></span> ${data.num_for_sale || 0}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Price Range:</b></span> ${data.sellerPriceRange || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Lowest Price:</b></span> ${data.lowPrice || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Last Sold:</b></span> ${data.lastSold || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Low Price:</b></span> ${data.lowPrice || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Median Price:</b></span> ${data.medianPrice || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>High Price:</b></span> ${data.highPrice || 'N/A'}</div>
|
||||
|
||||
<h3><b>Suggested Prices:</b></h3>
|
||||
<div class="data-row"><span class="data-label"><b>Mint (M):</b></span> ${data.priceSuggestions?.['Mint (M)']?.value?.toFixed(2) || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Near Mint (NM or M-):</b></span> ${data.priceSuggestions?.['Near Mint (NM or M-)']?.value?.toFixed(2) || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Very Good Plus (VG+):</b></span> ${data.priceSuggestions?.['Very Good Plus (VG+)']?.value?.toFixed(2) || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Very Good (VG):</b></span> ${data.priceSuggestions?.['Very Good (VG)']?.value?.toFixed(2) || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Good Plus (G+):</b></span> ${data.priceSuggestions?.['Good Plus (G+)']?.value?.toFixed(2) || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Good (G):</b></span> ${data.priceSuggestions?.['Good (G)']?.value?.toFixed(2) || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Fair (F):</b></span> ${data.priceSuggestions?.['Fair (F)']?.value?.toFixed(2) || 'N/A'}</div>
|
||||
<div class="data-row"><span class="data-label"><b>Poor (P):</b></span> ${data.priceSuggestions?.['Poor (P)']?.value?.toFixed(2) || 'N/A'}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
const tracklistDiv = document.querySelector('.tracklist');
|
||||
if (tracklistDiv) {
|
||||
const tracklistHTML = Array.isArray(data.tracklist) ? data.tracklist.map(entry => {
|
||||
if (entry.type_ === "heading") {
|
||||
return `<div class="data-row"><span class="data-label"><b>${entry.title}</b></span></div>`;
|
||||
} else if (entry.type_ === "track") {
|
||||
const artists = Array.isArray(entry.artists) ? entry.artists.map(artist => artist.name.replace(/\(\d+\)/, '')).join(', ') : '';
|
||||
return `<div class="data-row">
|
||||
<span class="data-label"><b>Track ${entry.position}:</b></span> ${entry.title}${artists ? ' - ' + artists : ''}
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
}).join('') : '';
|
||||
|
||||
tracklistDiv.innerHTML = `
|
||||
<h3><b>Tracklist:</b></h3>
|
||||
${tracklistHTML}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function generateLabel() {
|
||||
const labelWindow = window.open('', '_blank', 'width=600,height=600');
|
||||
if (labelWindow) {
|
||||
const labelSize = document.getElementById('labelSize')?.value || 'large';
|
||||
const cssFile = labelSize === 'xlarge' ? 'xlarge.css' : (labelSize === 'large' ? 'large.css' : 'small.css');
|
||||
const previewSel = labelSize === 'xlarge' ? '.label-preview-xlarge' : (labelSize === 'large' ? '.label-preview-large' : '.label-preview');
|
||||
const pageDims = labelSize === 'xlarge' ? '64mm 34mm' : (labelSize === 'large' ? '54mm 25mm' : '51mm 19mm');
|
||||
const labelClass = labelSize === 'xlarge' ? 'print-label-xlarge' : (labelSize === 'large' ? 'print-label-large' : 'print-label');
|
||||
const labelW = labelSize === 'xlarge' ? '64mm' : (labelSize === 'large' ? '54mm' : '51mm');
|
||||
const labelH = labelSize === 'xlarge' ? '34mm' : (labelSize === 'large' ? '25mm' : '19mm');
|
||||
|
||||
const printFont = document.getElementById('label-font-select')?.value || '"MS Gothic","Hiragino Kaku Gothic Pro",Arial,sans-serif';
|
||||
const printWeight = document.getElementById('label-bold-toggle')?.checked ? 'bold' : 'normal';
|
||||
const printStyle = document.getElementById('label-italic-toggle')?.checked ? 'italic' : 'normal';
|
||||
|
||||
const labelContent = document.querySelector(previewSel).innerHTML;
|
||||
|
||||
labelWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="${chrome.runtime.getURL(cssFile)}">
|
||||
<link rel="stylesheet" href="${chrome.runtime.getURL('label.css')}">
|
||||
<style>
|
||||
@page {
|
||||
size: ${pageDims};
|
||||
margin: 0;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: ${labelW};
|
||||
height: ${labelH};
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.${labelClass} {
|
||||
width: ${labelW};
|
||||
height: ${labelH};
|
||||
position: relative;
|
||||
font-family: ${printFont};
|
||||
font-weight: ${printWeight};
|
||||
font-style: ${printStyle};
|
||||
background: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="${labelClass}">
|
||||
${labelContent}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
labelWindow.document.close();
|
||||
}
|
||||
}
|
||||
|
||||
function generateLabel203dpi() {
|
||||
const labelWindow = window.open('', '_blank', 'width=600,height=600');
|
||||
if (!labelWindow) return;
|
||||
|
||||
const labelSize = document.getElementById('labelSize')?.value || 'large';
|
||||
const cssFile = labelSize === 'xlarge' ? 'xlarge.css' : (labelSize === 'large' ? 'large.css' : 'small.css');
|
||||
const previewSel = labelSize === 'xlarge' ? '.label-preview-xlarge' : (labelSize === 'large' ? '.label-preview-large' : '.label-preview');
|
||||
const pageDims = labelSize === 'xlarge' ? '64mm 34mm' : (labelSize === 'large' ? '54mm 25mm' : '51mm 19mm');
|
||||
const labelClass = labelSize === 'xlarge' ? 'print-label-xlarge' : (labelSize === 'large' ? 'print-label-large' : 'print-label');
|
||||
const labelW = labelSize === 'xlarge' ? '64mm' : (labelSize === 'large' ? '54mm' : '51mm');
|
||||
const labelH = labelSize === 'xlarge' ? '34mm' : (labelSize === 'large' ? '25mm' : '19mm');
|
||||
|
||||
const printFont = document.getElementById('label-font-select')?.value || '"MS Gothic","Hiragino Kaku Gothic Pro",Arial,sans-serif';
|
||||
const printWeight = document.getElementById('label-bold-toggle')?.checked ? 'bold' : 'normal';
|
||||
const printStyle = document.getElementById('label-italic-toggle')?.checked ? 'italic' : 'normal';
|
||||
|
||||
const labelContent = document.querySelector(previewSel).innerHTML;
|
||||
|
||||
labelWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="${chrome.runtime.getURL(cssFile)}">
|
||||
<link rel="stylesheet" href="${chrome.runtime.getURL('label.css')}">
|
||||
<style>
|
||||
@page {
|
||||
size: ${pageDims};
|
||||
margin: 0;
|
||||
/* Request 203dpi from the print engine */
|
||||
resolution: 203dpi;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: ${labelW};
|
||||
height: ${labelH};
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.${labelClass} {
|
||||
width: ${labelW};
|
||||
height: ${labelH};
|
||||
position: relative;
|
||||
font-family: ${printFont};
|
||||
font-weight: ${printWeight};
|
||||
font-style: ${printStyle};
|
||||
background: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 203dpi test watermark — screen only, does not print */
|
||||
@media screen {
|
||||
body::after {
|
||||
content: '203 DPI TEST';
|
||||
position: fixed;
|
||||
bottom: 4px;
|
||||
right: 6px;
|
||||
font-size: 10px;
|
||||
color: #aaa;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="${labelClass}">
|
||||
${labelContent}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
labelWindow.document.close();
|
||||
}
|
||||
142
utils.js
Normal file
142
utils.js
Normal file
@ -0,0 +1,142 @@
|
||||
// Cache-busting comment - Updated to fix ES6 syntax issues - v2.0
|
||||
const fetchWithRetry = async (url, options, maxRetries = 3) => {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
throw new Error('Empty response received');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
console.error('JSON Parse Error:', e, 'Response:', text);
|
||||
throw e;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Attempt ${i + 1} failed:`, error);
|
||||
if (i === maxRetries - 1) throw error;
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); // Exponential backoff
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function for getServiceAccountToken
|
||||
async function getGoogleSettings() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(['googleSettings'], (result) => {
|
||||
// console.log('Retrieved Google settings:', result.googleSettings);
|
||||
resolve(result.googleSettings || null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getServiceAccountToken() {
|
||||
try {
|
||||
const googleSettings = await getGoogleSettings();
|
||||
if (!googleSettings || !googleSettings.clientEmail || !googleSettings.privateKey) {
|
||||
throw new Error('Google credentials not configured.');
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const jwtHeader = {
|
||||
alg: 'RS256',
|
||||
typ: 'JWT'
|
||||
};
|
||||
|
||||
const jwtPayload = {
|
||||
iss: googleSettings.clientEmail,
|
||||
sub: googleSettings.clientEmail,
|
||||
aud: 'https://oauth2.googleapis.com/token',
|
||||
scope: 'https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive.file',
|
||||
exp: now + 3600,
|
||||
iat: now
|
||||
};
|
||||
|
||||
const base64Header = btoa(JSON.stringify(jwtHeader)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
const base64Payload = btoa(JSON.stringify(jwtPayload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
const signInput = `${base64Header}.${base64Payload}`;
|
||||
|
||||
const pemHeader = "-----BEGIN PRIVATE KEY-----";
|
||||
const pemFooter = "-----END PRIVATE KEY-----";
|
||||
const pemContents = googleSettings.privateKey
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(pemHeader, '')
|
||||
.replace(pemFooter, '')
|
||||
.replace(/\s/g, '');
|
||||
|
||||
const binaryKey = Uint8Array.from(atob(pemContents), c => c.charCodeAt(0));
|
||||
|
||||
const cryptoKey = await window.crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
binaryKey,
|
||||
{
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
hash: { name: 'SHA-256' },
|
||||
},
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await window.crypto.subtle.sign(
|
||||
'RSASSA-PKCS1-v1_5',
|
||||
cryptoKey,
|
||||
new TextEncoder().encode(signInput)
|
||||
);
|
||||
|
||||
const jwt = `${signInput}.${btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`;
|
||||
|
||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: jwt
|
||||
})
|
||||
});
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
console.error('Token Error:', tokenData);
|
||||
throw new Error(`Token error: ${tokenData.error_description || tokenData.error}`);
|
||||
}
|
||||
|
||||
return tokenData.access_token;
|
||||
} catch (error) {
|
||||
console.error('Detailed token error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePageAction(pageType, releaseId, displayContainerId, updateButtonId, dataAction, updateFunction) {
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tabs[0].id },
|
||||
files: ['content.js']
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const response = await chrome.tabs.sendMessage(tabs[0].id, {
|
||||
action: dataAction,
|
||||
releaseId: releaseId
|
||||
});
|
||||
|
||||
const displayContainer = document.getElementById(displayContainerId);
|
||||
if (response) {
|
||||
updateFunction(response, displayContainer, updateButtonId, releaseId);
|
||||
} else {
|
||||
displayContainer.innerHTML = `<div class="data-row">Error fetching ${pageType} data</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById(displayContainerId).innerHTML = `<div class="data-row">Error: ${error.message || `Failed to fetch ${pageType} data`}</div>`;
|
||||
}
|
||||
}
|
||||
198
wp.js
Normal file
198
wp.js
Normal file
@ -0,0 +1,198 @@
|
||||
function saveWpSettings() {
|
||||
const endpoint = document.getElementById('wpEndpoint').value;
|
||||
const key = document.getElementById('wpKey').value;
|
||||
const secret = document.getElementById('wpSecret').value;
|
||||
|
||||
const wpSettings = { endpoint, key, secret };
|
||||
|
||||
chrome.storage.local.set({ wpSettings }, () => {
|
||||
// console.log('WordPress settings saved');
|
||||
showStatusMessage('wpStatus', 'WordPress settings saved successfully!', true);
|
||||
});
|
||||
}
|
||||
|
||||
function loadWpSettings() {
|
||||
chrome.storage.local.get(['wpSettings'], (result) => {
|
||||
if (result.wpSettings) {
|
||||
// console.log('Loading WordPress settings:', result.wpSettings);
|
||||
document.getElementById('wpEndpoint').value = result.wpSettings.endpoint || '';
|
||||
document.getElementById('wpKey').value = result.wpSettings.key || '';
|
||||
document.getElementById('wpSecret').value = result.wpSettings.secret || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function testWpConnection() {
|
||||
console.log('--- Testing WordPress Connection ---');
|
||||
try {
|
||||
const settings = await new Promise(resolve => chrome.storage.local.get('wpSettings', data => resolve(data.wpSettings)));
|
||||
|
||||
if (!settings || !settings.endpoint) {
|
||||
throw new Error('Endpoint URL is not configured.');
|
||||
}
|
||||
|
||||
console.log('TESTING: Using endpoint:', settings.endpoint);
|
||||
|
||||
const response = await fetch(settings.endpoint, {
|
||||
method: 'GET', // Use GET for a simple test
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
if (response.ok) {
|
||||
console.log('TEST SUCCESS: Server responded with:', responseText);
|
||||
alert('Connection successful!');
|
||||
} else {
|
||||
console.error('TEST FAILED: Server responded with an error.', `Status: ${response.status}`, `Response: ${responseText}`);
|
||||
throw new Error(`Connection failed: ${responseText}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('TEST FATAL: An exception occurred during the connection test.', error);
|
||||
alert(`Connection test failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToWordPress(releaseData) {
|
||||
console.log('--- Starting WordPress Import ---');
|
||||
|
||||
if (!releaseData) {
|
||||
console.error('WP ERROR: releaseData object is missing.');
|
||||
alert('WP ERROR: releaseData object is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('WP DATA: Full releaseData object received:', releaseData);
|
||||
|
||||
try {
|
||||
const settings = await new Promise(resolve => chrome.storage.local.get('wpSettings', data => resolve(data.wpSettings)));
|
||||
|
||||
if (!settings || !settings.endpoint || !settings.key || !settings.secret) {
|
||||
throw new Error('WordPress settings are not configured. Please save your settings first.');
|
||||
}
|
||||
|
||||
console.log('WP SETTINGS: Found endpoint and credentials.');
|
||||
|
||||
// Safely construct the payload
|
||||
const payload = {
|
||||
release_id: releaseData.id || 'N/A',
|
||||
price: releaseData.price || '0.00',
|
||||
comment: releaseData.comment || '',
|
||||
media_condition: releaseData.mediaCondition || 'N/A',
|
||||
sleeve_condition: releaseData.sleeveCondition || 'N/A',
|
||||
apple_id: releaseData.appleId || 'N/A',
|
||||
market_data: {
|
||||
have: releaseData.have || 0,
|
||||
want: releaseData.want || 0,
|
||||
num_for_sale: releaseData.num_for_sale || 0,
|
||||
last_sold: releaseData.lastSold || 'N/A',
|
||||
low_price: releaseData.lowPrice || 'N/A',
|
||||
median_price: releaseData.medianPrice || 'N/A',
|
||||
high_price: releaseData.highPrice || 'N/A',
|
||||
}
|
||||
};
|
||||
|
||||
console.log('WP PAYLOAD: Sending the following data to WordPress:', JSON.stringify(payload, null, 2));
|
||||
|
||||
const response = await fetch(settings.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-API-Key': settings.key,
|
||||
'X-WP-API-Secret': settings.secret
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
if (response.ok) {
|
||||
console.log('WP SUCCESS: Server responded with:', responseText);
|
||||
alert('Successfully sent to WordPress!');
|
||||
} else {
|
||||
console.error('WP ERROR: Server responded with an error.', `Status: ${response.status}`, `Response: ${responseText}`);
|
||||
throw new Error(`WordPress API Error: ${responseText}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('WP FATAL: An exception occurred during the WordPress import process.', error);
|
||||
alert(`An error occurred: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function importToWordPress(releaseData) {
|
||||
// console.log('Starting WordPress import with data:', releaseData);
|
||||
|
||||
try {
|
||||
const wpSettings = await getWpSettings();
|
||||
if (!wpSettings || !wpSettings.endpoint || !wpSettings.key || !wpSettings.secret) {
|
||||
return { success: false, message: 'WordPress settings not configured' };
|
||||
}
|
||||
|
||||
// console.log('WordPress settings loaded:', {
|
||||
// endpoint: wpSettings.endpoint,
|
||||
// hasKey: !!wpSettings.key,
|
||||
// hasSecret: !!wpSettings.secret
|
||||
// });
|
||||
|
||||
const postData = {
|
||||
title: `${releaseData.artist} - ${releaseData.title}`,
|
||||
content: generateWordPressContent(releaseData),
|
||||
status: 'draft',
|
||||
categories: [1], // Default category
|
||||
tags: releaseData.genres ? releaseData.genres.join(', ') : ''
|
||||
};
|
||||
|
||||
// console.log('Prepared post data:', postData);
|
||||
|
||||
const auth = btoa(`${wpSettings.key}:${wpSettings.secret}`);
|
||||
|
||||
const response = await fetch(`${wpSettings.endpoint}/wp-json/wp/v2/posts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Basic ${auth}`
|
||||
},
|
||||
body: JSON.stringify(postData)
|
||||
});
|
||||
|
||||
// console.log('WordPress API response status:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
// console.log('WordPress post created successfully:', result);
|
||||
return {
|
||||
success: true,
|
||||
message: `WordPress post created successfully! Post ID: ${result.id}`,
|
||||
postId: result.id,
|
||||
editUrl: result.link
|
||||
};
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
// console.error('WordPress API error:', response.status, errorText);
|
||||
return {
|
||||
success: false,
|
||||
message: `WordPress API error: ${response.status} - ${errorText}`
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// console.error('Error importing to WordPress:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Error importing to WordPress: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getWpSettings() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(['wpSettings'], (result) => {
|
||||
// console.log('Retrieved WordPress settings:', result.wpSettings);
|
||||
resolve(result.wpSettings || null);
|
||||
});
|
||||
});
|
||||
}
|
||||
175
xlarge.css
Normal file
175
xlarge.css
Normal file
@ -0,0 +1,175 @@
|
||||
/* XLarge Label Preview (64mm × 34mm) */
|
||||
.label-preview-xlarge {
|
||||
font-family: "MS Gothic", "Hiragino Kaku Gothic Pro", Arial, sans-serif;
|
||||
width: 64mm;
|
||||
height: 34mm;
|
||||
position: relative;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0.1mm solid black;
|
||||
}
|
||||
|
||||
.print-label-xlarge {
|
||||
font-family: "MS Gothic", "Hiragino Kaku Gothic Pro", Arial, sans-serif;
|
||||
width: 64mm !important;
|
||||
height: 34mm;
|
||||
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;
|
||||
}
|
||||
|
||||
/* XLarge Label Field Positions — left column (0–47mm), right column (47–64mm) */
|
||||
.print-artist-xlarge {
|
||||
left: 0mm;
|
||||
top: 0mm;
|
||||
width: 47mm;
|
||||
height: 5mm;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.print-title-xlarge {
|
||||
left: 0mm;
|
||||
top: 5mm;
|
||||
width: 47mm;
|
||||
height: 5mm;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.print-genre-xlarge {
|
||||
left: 0mm;
|
||||
top: 10mm;
|
||||
width: 47mm;
|
||||
height: 4mm;
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
.print-style-xlarge {
|
||||
left: 0mm;
|
||||
top: 14mm;
|
||||
width: 47mm;
|
||||
height: 4.5mm;
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.print-description-xlarge {
|
||||
left: 0mm;
|
||||
top: 18.5mm;
|
||||
width: 47mm;
|
||||
height: 3.5mm;
|
||||
font-size: 7pt;
|
||||
}
|
||||
|
||||
.print-comment-xlarge {
|
||||
left: 0mm;
|
||||
top: 22mm;
|
||||
width: 47mm;
|
||||
height: 3.5mm;
|
||||
font-size: 7pt;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.print-notes-xlarge {
|
||||
left: 0mm;
|
||||
top: 25.5mm;
|
||||
width: 47mm;
|
||||
height: 3.5mm;
|
||||
font-size: 6pt;
|
||||
}
|
||||
|
||||
.print-bottom-info-xlarge {
|
||||
left: 0mm;
|
||||
top: 29mm;
|
||||
width: 47mm;
|
||||
height: 3.5mm;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.print-bottom-info-xlarge .year {
|
||||
width: auto;
|
||||
font-size: 8pt !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.print-bottom-info-xlarge .country {
|
||||
width: auto;
|
||||
min-width: 4mm;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.print-bottom-info-xlarge .label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.print-logo-xlarge {
|
||||
position: absolute;
|
||||
left: 47mm;
|
||||
top: 0mm;
|
||||
width: 17mm;
|
||||
height: 3mm;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.print-price-xlarge {
|
||||
position: absolute;
|
||||
left: 47mm;
|
||||
top: 3mm;
|
||||
width: 17mm;
|
||||
height: 7mm;
|
||||
font-size: 15pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.print-condition-xlarge {
|
||||
position: absolute;
|
||||
left: 47mm;
|
||||
top: 10mm;
|
||||
width: 17mm;
|
||||
height: 3mm;
|
||||
font-size: 7pt;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.print-barcode-xlarge {
|
||||
position: absolute;
|
||||
left: 47mm;
|
||||
top: 13mm;
|
||||
width: 17mm;
|
||||
height: 17mm;
|
||||
}
|
||||
|
||||
.print-barcode-xlarge img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.print-label-xlarge {
|
||||
width: 64mm !important;
|
||||
height: 34mm !important;
|
||||
}
|
||||
}
|
||||
|
||||
@page xlarge-label {
|
||||
size: 64mm 34mm;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user