// 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; }