您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Complete review writing toolkit with Unicode formatting, templates, phrases, auto-save, and cloud sync
// ==UserScript== // @name Amazon Review Toolkit // @namespace http://tampermonkey.net/ // @version 1.0 // @description Complete review writing toolkit with Unicode formatting, templates, phrases, auto-save, and cloud sync // @author Prismaris // @match https://www.amazon.com/review/review-your-purchases* // @match https://www.amazon.com/review/create-review* // @match https://www.amazon.com/reviews/edit-review/* // @match https://www.amazon.ca/review/review-your-purchases* // @match https://www.amazon.ca/review/create-review* // @match https://www.amazon.ca/reviews/edit-review/* // @match https://www.amazon.co.uk/review/review-your-purchases* // @match https://www.amazon.co.uk/review/create-review* // @match https://www.amazon.co.uk/reviews/edit-review/* // @match https://www.amazon.de/review/review-your-purchases* // @match https://www.amazon.de/review/create-review* // @match https://www.amazon.de/reviews/edit-review/* // @match https://www.amazon.fr/review/review-your-purchases* // @match https://www.amazon.fr/review/create-review* // @match https://www.amazon.fr/reviews/edit-review/* // @match https://www.amazon.it/review/review-your-purchases* // @match https://www.amazon.it/review/create-review* // @match https://www.amazon.it/reviews/edit-review/* // @match https://www.amazon.es/review/review-your-purchases* // @match https://www.amazon.es/review/create-review* // @match https://www.amazon.es/reviews/edit-review/* // @match https://www.amazon.co.jp/review/review-your-purchases* // @match https://www.amazon.co.jp/review/create-review* // @match https://www.amazon.co.jp/reviews/edit-review/* // @match https://www.amazon.in/review/review-your-purchases* // @match https://www.amazon.in/review/create-review* // @match https://www.amazon.in/reviews/edit-review/* // @match https://www.amazon.com.au/review/review-your-purchases* // @match https://www.amazon.com.au/review/create-review* // @match https://www.amazon.com.au/reviews/edit-review/* // @grant GM_xmlhttpRequest // @connect pastebin.com // @license MIT // ==/UserScript== (function() { 'use strict'; // Add CSS styles const styles = ` #reviewText { min-height: 28em !important; /* height: 28em !important; */ resize: vertical; } .unicode-toolbar { position: relative; z-index: 10; display: flex; gap: 8px; margin-bottom: 6px; align-items: center; } .unicode-toolbar button { position: relative; overflow: hidden; will-change: transform, background-color; font-size: 1.1em; padding: 2px 8px; border-radius: 4px; border: 1px solid #bbb; background: #f8f8f8; cursor: pointer; transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease; outline: none; user-select: none; } .unicode-toolbar button:active { transform: scale(0.95); } .unicode-toolbar button:focus { outline: 2px solid #1976d2; outline-offset: 2px; } .unicode-toolbar button[aria-pressed="true"] { background-color: #1976d2 !important; color: #fff !important; } .unicode-toolbar button[aria-pressed="false"] { background-color: #f8f8f8 !important; color: inherit !important; } .unicode-toolbar button { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } /* Drag-and-drop highlight for media upload */ .in-context-ryp__form-field--mediaUploadInput--custom-wrapper.dragover { outline: 2px solid #2196f3 !important; box-shadow: 0 0 0 2px #2196f3 !important; background: #e3f2fd !important; transition: outline 0.2s, box-shadow 0.2s, background 0.2s; } /* Pastebin popover styles */ .pastebin-popover { position: absolute; top: 100%; right: 0; background: white; border: 1px solid #ddd; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 1000; min-width: 200px; display: none; font-size: 14px; } .pastebin-popover.show { display: block; } .pastebin-popover-item { padding: 8px 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; transition: background 0.15s; } .pastebin-popover-item:hover { background: #f5f5f5; } .pastebin-popover-item:first-child { border-radius: 6px 6px 0 0; } .pastebin-popover-item:last-child { border-radius: 0 0 6px 6px; } .pastebin-popover-item:not(:last-child) { border-bottom: 1px solid #eee; } .pastebin-popover-item.disabled { opacity: 0.5; cursor: not-allowed; } .pastebin-popover-item.disabled:hover { background: transparent; } /* Configuration modal styles */ .pastebin-modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 10000; display: none; align-items: center; justify-content: center; } .pastebin-modal.show { display: flex; } .pastebin-modal-content { background: white; border-radius: 8px; padding: 24px; max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; } .pastebin-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .pastebin-modal-title { font-size: 18px; font-weight: 600; margin: 0; } .pastebin-modal-close { background: none; border: none; font-size: 20px; cursor: pointer; padding: 0; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: background 0.15s; } .pastebin-modal-close:hover { background: #f5f5f5; } .pastebin-form-group { margin-bottom: 16px; } .pastebin-form-label { display: block; font-weight: 500; margin-bottom: 4px; color: #333; } .pastebin-form-input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; } .pastebin-form-input:focus { outline: none; border-color: #1976d2; box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2); } .pastebin-form-input[readonly] { background: #f8f8f8; color: #666; } .pastebin-form-help { font-size: 12px; color: #666; margin-top: 4px; } .pastebin-form-help a { color: #1976d2; text-decoration: none; } .pastebin-form-help a:hover { text-decoration: underline; } .pastebin-form-actions { display: flex; gap: 8px; margin-top: 20px; flex-wrap: wrap; } .pastebin-btn { padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; background: #f8f8f8; cursor: pointer; font-size: 14px; transition: all 0.15s; min-width: 100px; } .pastebin-btn:hover { background: #e8e8e8; } .pastebin-btn:disabled { opacity: 0.6; cursor: not-allowed; } .pastebin-btn-primary { background: #1976d2; color: white; border-color: #1976d2; } .pastebin-btn-primary:hover { background: #1565c0; } /* Sync status indicators */ .template-sync-status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-left: 6px; } .sync-status-synced { background: #4caf50; } .sync-status-pending { background: #ff9800; } .sync-status-failed { background: #f44336; } .sync-status-none { background: #ccc; } /* Loading spinner */ .pastebin-loading { display: inline-block; width: 12px; height: 12px; border: 2px solid #f3f3f3; border-top: 2px solid #1976d2; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 6px; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* Template manager modal styles */ .template-manager-modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 10000; display: none; align-items: center; justify-content: center; } .template-manager-modal.show { display: flex; } .template-manager-content { background: white; border-radius: 8px; padding: 24px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; } .template-manager-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .template-manager-title { font-size: 18px; font-weight: 600; margin: 0; } .template-manager-close { background: none; border: none; font-size: 20px; cursor: pointer; padding: 0; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: background 0.15s; } .template-manager-close:hover { background: #f5f5f5; } .template-manager-body { max-height: 400px; overflow-y: auto; } .template-list { display: flex; flex-direction: column; gap: 12px; } .template-item { display: flex; justify-content: space-between; align-items: center; padding: 12px; border: 1px solid #e0e0e0; border-radius: 6px; background: #fafafa; transition: all 0.15s; } .template-item:hover { background: #f5f5f5; border-color: #ccc; } .template-info { flex: 1; min-width: 0; } .template-name { font-weight: 500; margin-bottom: 4px; color: #333; } .template-preview { font-size: 12px; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .template-actions { display: flex; gap: 8px; margin-left: 12px; } .template-btn { padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px; background: white; cursor: pointer; font-size: 14px; transition: all 0.15s; min-width: 32px; } .template-btn:hover { background: #f0f0f0; } .template-btn:disabled { opacity: 0.6; cursor: not-allowed; } .template-insert-btn:hover { background: #e8f5e8; border-color: #4caf50; } .template-delete-btn:hover { background: #ffe8e8; border-color: #f44336; } .no-templates { text-align: center; color: #666; font-style: italic; padding: 40px 20px; } /* Tab styles */ .template-manager-tabs { display: flex; border-bottom: 1px solid #e0e0e0; margin-bottom: 20px; } .tab-btn { background: none; border: none; padding: 12px 20px; cursor: pointer; font-size: 14px; font-weight: 500; color: #666; border-bottom: 2px solid transparent; transition: all 0.15s; } .tab-btn:hover { background: #f5f5f5; color: #333; } .tab-btn.active { color: #1976d2; border-bottom-color: #1976d2; background: #f8f9fa; } .tab-content { animation: fadeIn 0.2s ease-in-out; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } `; // Inject CSS const styleSheet = document.createElement('style'); styleSheet.textContent = styles; document.head.appendChild(styleSheet); // Add CSS for uploading state const uploadStyle = document.createElement('style'); uploadStyle.textContent = ` .amazon-uploading { position: relative; } .amazon-uploading .google-photos-btn, .amazon-uploading .amazon-dnd-dragtext, .amazon-uploading [aria-label="Add a photo"], .amazon-uploading .add-photo-btn, .amazon-uploading > div, /* fallback for + button */ .amazon-uploading > button { opacity: 0.2 !important; pointer-events: none !important; filter: blur(1px); } .amazon-uploading .amazon-dnd-pastetext { opacity: 1 !important; filter: none !important; } `; document.head.appendChild(uploadStyle); // --- PASTEBIN API CONFIGURATION --- const PASTEBIN_CONFIG = { API_URL: 'https://pastebin.com/api/api_post.php', LOGIN_URL: 'https://pastebin.com/api/api_login.php', API_DEV_KEY: null, API_USER_KEY: null, API_USER_NAME: null, API_USER_PASSWORD: null, PASTE_FORMAT: 'text', PASTE_PRIVACY: '0', // 0=public, 1=unlisted, 2=private PASTE_EXPIRE: 'N' // N=never, 10M=10 minutes, 1H=1 hour, 1D=1 day, 1W=1 week, 2W=2 weeks, 1M=1 month, 6M=6 months, 1Y=1 year }; // Load configuration from localStorage function loadPastebinConfig() { try { const saved = localStorage.getItem('amazon_pastebin_config'); if (saved) { const config = JSON.parse(saved); PASTEBIN_CONFIG.API_DEV_KEY = config.api_dev_key || null; PASTEBIN_CONFIG.API_USER_KEY = config.api_user_key || null; PASTEBIN_CONFIG.API_USER_NAME = config.api_user_name || null; PASTEBIN_CONFIG.API_USER_PASSWORD = config.api_user_password || null; } } catch (e) { console.error('Error loading Pastebin config:', e); } } // Save configuration to localStorage function savePastebinConfig() { try { const config = { api_dev_key: PASTEBIN_CONFIG.API_DEV_KEY, api_user_key: PASTEBIN_CONFIG.API_USER_KEY, api_user_name: PASTEBIN_CONFIG.API_USER_NAME, api_user_password: PASTEBIN_CONFIG.API_USER_PASSWORD }; localStorage.setItem('amazon_pastebin_config', JSON.stringify(config)); } catch (e) { console.error('Error saving Pastebin config:', e); } } // Generate API User Key using Pastebin login API async function generatePastebinUserKey(username, password) { if (!PASTEBIN_CONFIG.API_DEV_KEY) { throw new Error('API Dev Key is required to generate User Key'); } const data = { api_dev_key: PASTEBIN_CONFIG.API_DEV_KEY, api_user_name: username, api_user_password: password }; // Convert to URLSearchParams for proper encoding const params = new URLSearchParams(); Object.entries(data).forEach(([key, value]) => { if (value !== null && value !== undefined) { params.append(key, value); } }); console.log('Generating Pastebin User Key for username:', username); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: PASTEBIN_CONFIG.LOGIN_URL, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: params.toString(), onload: function(response) { const result = response.responseText.trim(); console.log('Pastebin login response:', result); // Check for API errors if (result.startsWith('Bad API request')) { console.error('Pastebin login failed:', result); reject(new Error(`Login failed: ${result}`)); return; } // Check if response looks like a valid user key (32 character hex string) if (result.length === 32 && /^[a-f0-9]+$/i.test(result)) { console.log('Successfully generated User Key'); resolve(result); } else { console.error('Unexpected login response format:', result); reject(new Error('Invalid response from Pastebin login API')); } }, onerror: function(error) { console.error('Pastebin login request failed:', error); reject(new Error('Network error during login. Please check your internet connection.')); } }); }); } // Check if Pastebin is configured function isPastebinConfigured() { const hasKey = !!(PASTEBIN_CONFIG.API_DEV_KEY); if (hasKey) { console.log('Pastebin API configured with dev key length:', PASTEBIN_CONFIG.API_DEV_KEY.length); console.log('Dev key format looks valid:', /^[a-zA-Z0-9_-]+$/.test(PASTEBIN_CONFIG.API_DEV_KEY)); } return hasKey; } // --- PASTEBIN API FUNCTIONS --- async function pastebinRequest(data) { if (!isPastebinConfigured()) { throw new Error('Pastebin API not configured'); } // Prepare the request data with proper parameter names const requestData = { api_dev_key: PASTEBIN_CONFIG.API_DEV_KEY, ...data }; // Convert to URLSearchParams for proper encoding const params = new URLSearchParams(); Object.entries(requestData).forEach(([key, value]) => { if (value !== null && value !== undefined) { params.append(key, value); } }); console.log('Sending Pastebin API request to:', PASTEBIN_CONFIG.API_URL); console.log('Request params:', params.toString()); console.log('Dev key being used:', PASTEBIN_CONFIG.API_DEV_KEY.substring(0, 10) + '...'); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: PASTEBIN_CONFIG.API_URL, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: params.toString(), onload: function(response) { const result = response.responseText; console.log('Pastebin API response status:', response.status); console.log('Pastebin API response:', result); console.log('Response length:', result.length); // Check for API errors if (result.startsWith('Bad API request')) { console.error('Pastebin API returned error:', result); reject(new Error(`Pastebin API Error: ${result}`)); return; } // Check if response looks like an error page if (result.includes('cors-anywhere') || result.includes('Access-Control-Allow-Origin') || result.includes('/corsdemo') || result.length < 10) { console.error('Unexpected response format:', result); reject(new Error('Unexpected response from Pastebin API. Please check your API keys.')); return; } console.log('Pastebin API request successful, response:', result); resolve(result); }, onerror: function(error) { console.error('Pastebin API request failed:', error); reject(new Error('Network error. Please check your internet connection and try again.')); } }); }); } // Create a new paste async function createPastebinPaste(name, content, isReview = false) { const data = { api_option: 'paste', api_paste_code: content, api_paste_name: isReview ? name : `Amazon Review Template: ${name}`, api_paste_format: isReview ? 'json' : PASTEBIN_CONFIG.PASTE_FORMAT, api_paste_private: PASTEBIN_CONFIG.PASTE_PRIVACY, api_paste_expire_date: PASTEBIN_CONFIG.PASTE_EXPIRE }; // Add user key if available (required for private pastes) if (PASTEBIN_CONFIG.API_USER_KEY) { data.api_user_key = PASTEBIN_CONFIG.API_USER_KEY; } console.log('Creating paste with data:', { ...data, api_paste_code: '[content]' }); const response = await pastebinRequest(data); console.log('Raw paste response:', response); // Validate the response if (!response || response.includes('Bad API request') || response.includes('error')) { throw new Error(`Pastebin API error: ${response}`); } // Extract paste key from response let pasteKey; if (response.startsWith('https://pastebin.com/')) { // Response is a full URL, extract the key pasteKey = response.split('/').pop(); console.log('Extracted paste key from URL:', pasteKey); } else { // Response is already a paste key pasteKey = response; } // Validate the paste key format if (!pasteKey || pasteKey.length !== 8) { throw new Error(`Invalid paste key format. Expected 8 characters, got: ${pasteKey}`); } return pasteKey; } // Note: Pastebin API does not support updating existing pastes // The updatePastebinPaste function has been removed as it's not functional // Instead, we use delete + recreate approach for all paste updates: // - Template updates: delete old paste, create new one // - Review updates: delete old paste, create new one // - Phrase sync: delete old phrases paste, create new one // Delete a paste async function deletePastebinPaste(pasteKey) { if (!PASTEBIN_CONFIG.API_USER_KEY) { throw new Error('User key required for deleting pastes'); } const data = { api_option: 'delete', api_user_key: PASTEBIN_CONFIG.API_USER_KEY, api_paste_key: pasteKey }; const result = await pastebinRequest(data); return result === 'Paste deleted successfully'; } // Get paste content async function getPastebinPaste(pasteKey) { const data = { api_option: 'show_paste', api_paste_key: pasteKey }; // Add user key if available (required for private pastes) if (PASTEBIN_CONFIG.API_USER_KEY) { data.api_user_key = PASTEBIN_CONFIG.API_USER_KEY; } const content = await pastebinRequest(data); return content; } // List user's pastes async function listUserPastes() { if (!PASTEBIN_CONFIG.API_USER_KEY) { throw new Error('User key required for listing pastes'); } const data = { api_option: 'list', api_user_key: PASTEBIN_CONFIG.API_USER_KEY, api_results_limit: 100 }; const result = await pastebinRequest(data); // Parse XML result const parser = new DOMParser(); const xmlDoc = parser.parseFromString(result, 'text/xml'); const pastes = xmlDoc.getElementsByTagName('paste'); console.log('Raw XML result:', result); console.log('XML parsing result:', xmlDoc); console.log('Found paste elements:', pastes); console.log('Number of paste elements:', pastes.length); const pasteList = []; console.log(`Processing ${pastes.length} pastes from Pastebin API...`); // If XML parsing didn't work properly, try manual parsing as fallback if (pastes.length === 0 || pastes.length === 1) { console.log('XML parsing may have failed, trying manual parsing...'); // Manual parsing using regex const pasteMatches = result.match(/<paste>([\s\S]*?)<\/paste>/g); if (pasteMatches) { console.log(`Manual parsing found ${pasteMatches.length} pastes`); for (let i = 0; i < pasteMatches.length; i++) { const pasteXml = pasteMatches[i]; console.log(`Manual parsing paste ${i + 1}:`, pasteXml); const titleMatch = pasteXml.match(/<paste_title>([^<]+)<\/paste_title>/); const keyMatch = pasteXml.match(/<paste_key>([^<]+)<\/paste_key>/); const dateMatch = pasteXml.match(/<paste_date>([^<]+)<\/paste_date>/); if (titleMatch && keyMatch && dateMatch) { const title = titleMatch[1]; const key = keyMatch[1]; const date = dateMatch[1]; console.log(` Manual extracted - Title: "${title}", Key: "${key}", Date: "${date}"`); // Include both Amazon Review Template pastes and review pastes if (title.startsWith('Amazon Product:') && title.includes(' — REVIEW — ')) { // This is a review paste (has the new Amazon Product prefix and review indicator) console.log(` -> Categorizing as REVIEW (has Amazon Product prefix and review indicator)`); pasteList.push({ key: key, title: title.replace('Amazon Product: ', ''), date: parseInt(date) * 1000, // Convert to milliseconds type: 'review' }); } else if (title.startsWith('Amazon Review Template:') && title.includes(' — REVIEW — ')) { // This is an old review paste (has old prefix and review indicator) - for backward compatibility console.log(` -> Categorizing as REVIEW (has old Amazon Review Template prefix and review indicator)`); pasteList.push({ key: key, title: title.replace('Amazon Review Template: ', ''), date: parseInt(date) * 1000, // Convert to milliseconds type: 'review' }); } else if (title.startsWith('Amazon Review Template:')) { // This is a template paste (has prefix but no review indicator) console.log(` -> Categorizing as TEMPLATE (has prefix but no review indicator)`); pasteList.push({ key: key, title: title.replace('Amazon Review Template: ', ''), date: parseInt(date) * 1000, // Convert to milliseconds type: 'template' }); } else if (title.includes(' — REVIEW — ')) { // This is a review paste (no prefix but has review indicator) console.log(` -> Categorizing as REVIEW (no prefix but has review indicator)`); pasteList.push({ key: key, title: title, date: parseInt(date) * 1000, // Convert to milliseconds type: 'review' }); } else { console.log(` -> Skipping (doesn't match any criteria)`); } } } } } else { // Use normal XML parsing for (let i = 0; i < pastes.length; i++) { const paste = pastes[i]; console.log(`Processing paste element ${i}:`, paste); const titleElement = paste.getElementsByTagName('paste_title')[0]; const keyElement = paste.getElementsByTagName('paste_key')[0]; const dateElement = paste.getElementsByTagName('paste_date')[0]; const title = titleElement?.textContent || ''; const key = keyElement?.textContent || ''; const date = dateElement?.textContent || ''; console.log(` Title element:`, titleElement); console.log(` Key element:`, keyElement); console.log(` Date element:`, dateElement); console.log(` Extracted values - Title: "${title}", Key: "${key}", Date: "${date}"`); console.log(`Processing paste ${i + 1}: "${title}"`); // Include both Amazon Review Template pastes and review pastes if (title.startsWith('Amazon Product:') && title.includes(' — REVIEW — ')) { // This is a review paste (has the new Amazon Product prefix and review indicator) console.log(` -> Categorizing as REVIEW (has Amazon Product prefix and review indicator)`); pasteList.push({ key: key, title: title.replace('Amazon Product: ', ''), date: parseInt(date) * 1000, // Convert to milliseconds type: 'review' }); } else if (title.startsWith('Amazon Review Template:') && title.includes(' — REVIEW — ')) { // This is an old review paste (has old prefix and review indicator) - for backward compatibility console.log(` -> Categorizing as REVIEW (has old Amazon Review Template prefix and review indicator)`); pasteList.push({ key: key, title: title.replace('Amazon Review Template: ', ''), date: parseInt(date) * 1000, // Convert to milliseconds type: 'review' }); } else if (title.startsWith('Amazon Review Template:')) { // This is a template paste (has prefix but no review indicator) console.log(` -> Categorizing as TEMPLATE (has prefix but no review indicator)`); pasteList.push({ key: key, title: title.replace('Amazon Review Template: ', ''), date: parseInt(date) * 1000, // Convert to milliseconds type: 'template' }); } else if (title.includes(' — REVIEW — ')) { // This is a review paste (no prefix but has review indicator) console.log(` -> Categorizing as REVIEW (no prefix but has review indicator)`); pasteList.push({ key: key, title: title, date: parseInt(date) * 1000, // Convert to milliseconds type: 'review' }); } else { console.log(` -> Skipping (doesn't match any criteria)`); } } } console.log(`Final paste list has ${pasteList.length} items:`, pasteList); return pasteList; } // Test API connection async function testPastebinConnection() { try { if (!isPastebinConfigured()) { return { success: false, message: 'API not configured' }; } console.log('Testing Pastebin connection with dev key:', PASTEBIN_CONFIG.API_DEV_KEY); console.log('User key available:', !!PASTEBIN_CONFIG.API_USER_KEY); // Try a simple test first - just check if we can get a response const testData = { api_option: 'paste', api_paste_code: 'Test connection', api_paste_name: 'Test', api_paste_format: 'text', api_paste_private: '0', api_paste_expire_date: 'N' }; // Add user key if available if (PASTEBIN_CONFIG.API_USER_KEY) { testData.api_user_key = PASTEBIN_CONFIG.API_USER_KEY; } console.log('Sending test request with data:', testData); // Try to create a test paste const testKey = await createPastebinPaste('Test Connection', 'This is a test paste to verify API connection.'); console.log('Test paste created successfully with key:', testKey); // Try to delete the test paste if (PASTEBIN_CONFIG.API_USER_KEY) { await deletePastebinPaste(testKey); console.log('Test paste deleted successfully'); } return { success: true, message: 'Connection successful' }; } catch (error) { console.error('Test connection failed:', error); // Provide specific error messages if (error.message.includes('Network error')) { return { success: false, message: 'Network error. Please check your internet connection and try again.' }; } else if (error.message.includes('Bad API request')) { return { success: false, message: `API Error: ${error.message}. Please check your API keys.` }; } else if (error.message.includes('Unexpected response')) { return { success: false, message: 'Unexpected response from Pastebin API. Please check your API keys.' }; } return { success: false, message: error.message }; } } // Load configuration on script start loadPastebinConfig(); // --- Unicode Style Maps --- // a-z and A-Z const unicodeMaps = { bold: { 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:'𝗭', 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:'𝘇' }, boldserif: { 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:'𝐙', 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:'𝐳' }, italic: { 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:'𝘡', 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:'𝘻' }, bolditalic: { 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:'𝙕', 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:'𝙯' }, serif: { 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:'𝑍', 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:'𝑧' }, serifitalic: { 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:'𝑍', 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:'𝑧' }, serifbolditalic: { 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:'𝒁', 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:'𝒛' }, cursive: { 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:'𝓩', 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:'𝓏' }, cursivebold: { 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:'𝓩', 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:'𝔃' }, superscript: { A:'ᴬ',B:'ᴮ',C:'ᶜ',D:'ᴰ',E:'ᴱ',F:'ᶠ',G:'ᴳ',H:'ᴴ',I:'ᴵ',J:'ᴶ',K:'ᴷ',L:'ᴸ',M:'ᴹ',N:'ᴺ',O:'ᴼ',P:'ᴾ',R:'ᴿ',S:'ˢ',T:'ᵀ',U:'ᵁ',V:'ⱽ',W:'ᵂ',X:'ˣ',Y:'ʸ',Z:'ᶻ', a:'ᵃ',b:'ᵇ',c:'ᶜ',d:'ᵈ',e:'ᵉ',f:'ᶠ',g:'ᵍ',h:'ʰ',i:'ᶦ',j:'ʲ',k:'ᵏ',l:'ˡ',m:'ᵐ',n:'ⁿ',o:'ᵒ',p:'ᵖ',r:'ʳ',s:'ˢ',t:'ᵗ',u:'ᵘ',v:'ᵛ',w:'ʷ',x:'ˣ',y:'ʸ',z:'ᶻ' }, underline: { A:'A͟',B:'B͟',C:'C͟',D:'D͟',E:'E͟',F:'F͟',G:'G͟',H:'H͟',I:'I͟',J:'J͟',K:'K͟',L:'L͟',M:'M͟',N:'N͟',O:'O͟',P:'P͟',Q:'Q͟',R:'R͟',S:'S͟',T:'T͟',U:'U͟',V:'V͟',W:'W͟',X:'X͟',Y:'Y͟',Z:'Z͟', a:'a͟',b:'b͟',c:'c͟',d:'d͟',e:'e͟',f:'f͟',g:'g͟',h:'h͟',i:'i͟',j:'j͟',k:'k͟',l:'l͟',m:'m͟',n:'n͟',o:'o͟',p:'p͟',q:'q͟',r:'r͟',s:'s͟',t:'t͟',u:'u͟',v:'v͟',w:'w͟',x:'x͟',y:'y͟',z:'z͟' }, monospace: { 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:'𝚉', 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:'𝚣' }, wide: { A:'A',B:'B',C:'C',D:'D',E:'E',F:'F',G:'G',H:'H',I:'I',J:'J',K:'K',L:'L',M:'M',N:'N',O:'O',P:'P',Q:'Q',R:'R',S:'S',T:'T',U:'U',V:'V',W:'W',X:'X',Y:'Y',Z:'Z', a:'a',b:'b',c:'c',d:'d',e:'e',f:'f',g:'g',h:'h',i:'i',j:'j',k:'k',l:'l',m:'m',n:'n',o:'o',p:'p',q:'q',r:'r',s:'s',t:'t',u:'u',v:'v',w:'w',x:'x',y:'y',z:'z' }, strikethrough: { A:'A̶',B:'B̶',C:'C̶',D:'D̶',E:'E̶',F:'F̶',G:'G̶',H:'H̶',I:'I̶',J:'J̶',K:'K̶',L:'L̶',M:'M̶',N:'N̶',O:'O̶',P:'P̶',Q:'Q̶',R:'R̶',S:'S̶',T:'T̶',U:'U̶',V:'V̶',W:'W̶',X:'X̶',Y:'Y̶',Z:'Z̶', a:'a̶',b:'b̶',c:'c̶',d:'d̶',e:'e̶',f:'f̶',g:'g̶',h:'h̶',i:'i̶',j:'j̶',k:'k̶',l:'l̶',m:'m̶',n:'n̶',o:'o̶',p:'p̶',q:'q̶',r:'r̶',s:'s̶',t:'t̶',u:'u̶',v:'v̶',w:'w̶',x:'x̶',y:'y̶',z:'z̶' } }; // --- Style Combination Logic --- const styleCombinationMap = [ { styles: ['superscript'], key: 'superscript' }, { styles: ['underline'], key: 'underline' }, { styles: ['monospace'], key: 'monospace' }, { styles: ['wide'], key: 'wide' }, { styles: ['strikethrough'], key: 'strikethrough' }, { styles: ['cursive', 'bold'], key: 'cursivebold' }, { styles: ['cursive'], key: 'cursive' }, { styles: ['serif', 'bold', 'italic'], key: 'serifbolditalic' }, { styles: ['serif', 'bold'], key: 'boldserif' }, { styles: ['serif', 'italic'], key: 'serifitalic' }, { styles: ['serif'], key: 'serif' }, { styles: ['bold', 'italic'], key: 'bolditalic' }, { styles: ['bold'], key: 'bold' }, { styles: ['italic'], key: 'italic' } ]; function stylize(text, styles) { if (styles.size === 0) return text; for (const combo of styleCombinationMap) { if (combo.styles.every(s => styles.has(s)) && combo.styles.length === styles.size) { const map = unicodeMaps[combo.key]; if (!map) return text; return [...text].map(ch => map[ch] || ch).join(''); } } return text; } function detectAppliedStyles(text) { const detectedStyles = new Set(); for (let i = 0; i < text.length; i++) { const char = text[i]; const two_chars = text.substring(i, i + 2); let styleFound = false; for (const [styleName, styleMap] of Object.entries(unicodeMaps)) { for (const [ascii, unicode] of Object.entries(styleMap)) { let match = false; if (unicode.length === 1 && char === unicode) { match = true; } else if (unicode.length > 1 && two_chars === unicode) { match = true; } if (match) { if (unicode.length > 1) i++; if (styleName === 'bold') detectedStyles.add('bold'); else if (styleName === 'italic') detectedStyles.add('italic'); else if (styleName === 'serif') detectedStyles.add('serif'); else if (styleName === 'cursive') detectedStyles.add('cursive'); else if (styleName === 'superscript') detectedStyles.add('superscript'); else if (styleName === 'underline') detectedStyles.add('underline'); else if (styleName === 'monospace') detectedStyles.add('monospace'); else if (styleName === 'wide') detectedStyles.add('wide'); else if (styleName === 'strikethrough') detectedStyles.add('strikethrough'); else if (styleName === 'boldserif') { detectedStyles.add('bold'); detectedStyles.add('serif'); } else if (styleName === 'bolditalic') { detectedStyles.add('bold'); detectedStyles.add('italic'); } else if (styleName === 'serifitalic') { detectedStyles.add('serif'); detectedStyles.add('italic'); } else if (styleName === 'serifbolditalic') { detectedStyles.add('serif'); detectedStyles.add('bold'); detectedStyles.add('italic'); } else if (styleName === 'cursivebold') { detectedStyles.add('cursive'); detectedStyles.add('bold'); } styleFound = true; break; } } if(styleFound) break; } } return detectedStyles; } function convertToPlainText(text) { let plainText = text; for (const [mapName, map] of Object.entries(unicodeMaps)) { for (const [ascii, uni] of Object.entries(map)) { plainText = plainText.replace(new RegExp(uni.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), ascii); } } return plainText; } // --- Toolbar UI --- function createToolbar(textarea) { const toolbar = document.createElement('div'); toolbar.className = 'unicode-toolbar'; toolbar.style.display = 'flex'; toolbar.style.gap = '8px'; toolbar.style.marginBottom = '6px'; toolbar.style.alignItems = 'center'; // --- TEMPLATE FEATURE --- // Enhanced template storage helpers with cloud sync support function getTemplates() { try { return JSON.parse(localStorage.getItem('amazon_review_templates') || '[]'); } catch (e) { return []; } } function saveTemplates(templates) { localStorage.setItem('amazon_review_templates', JSON.stringify(templates)); } // --- PHRASE STORAGE FUNCTIONS --- function getPhrases() { try { return JSON.parse(localStorage.getItem('amazon_review_phrases') || '[]'); } catch (e) { return []; } } function savePhrases(phrases) { localStorage.setItem('amazon_review_phrases', JSON.stringify(phrases)); } async function upsertPhrase(name, text) { let phrases = getPhrases(); const idx = phrases.findIndex(p => p.name === name); const now = Date.now(); if (idx !== -1) { // Update existing phrase phrases[idx].text = text; phrases[idx].lastModified = now; phrases[idx].syncStatus = 'pending'; } else { // Create new phrase const newPhrase = { name: name, text: text, lastModified: now, syncStatus: 'none' }; phrases.push(newPhrase); } savePhrases(phrases); // Sync to cloud if configured if (isPastebinConfigured()) { try { await syncPhrasesToCloud(); } catch (error) { console.error('Failed to sync phrases to cloud:', error); } } return phrases[idx !== -1 ? idx : phrases.length - 1]; } function getPhraseByName(name) { return getPhrases().find(p => p.name === name); } async function removePhrase(name) { let phrases = getPhrases(); phrases = phrases.filter(p => p.name !== name); savePhrases(phrases); // Sync to cloud if configured if (isPastebinConfigured()) { try { await syncPhrasesToCloud(); } catch (error) { console.error('Failed to sync phrases to cloud:', error); } } } // Enhanced template structure with cloud sync support async function upsertTemplate(name, text, height) { let templates = getTemplates(); const idx = templates.findIndex(t => t.name === name); const now = Date.now(); if (idx !== -1) { // Update existing template templates[idx].text = text; templates[idx].height = height; templates[idx].lastModified = now; templates[idx].syncStatus = 'pending'; // Update cloud paste if it exists (using delete + recreate since Pastebin doesn't support updates) if (templates[idx].pasteCode && isPastebinConfigured()) { try { // Delete old paste and create new one await deletePastebinPaste(templates[idx].pasteCode); const newPasteCode = await createPastebinPaste(name, JSON.stringify({ name: name, text: text, height: height, lastModified: now })); templates[idx].pasteCode = newPasteCode; templates[idx].syncStatus = 'synced'; templates[idx].lastSynced = now; } catch (error) { console.error('Failed to update cloud template:', error); templates[idx].syncStatus = 'failed'; } } } else { // Create new template const newTemplate = { name: name, text: text, height: height, lastModified: now, syncStatus: 'none', pasteCode: null, lastSynced: null }; // Create cloud paste if configured if (isPastebinConfigured()) { try { const pasteCode = await createPastebinPaste(name, JSON.stringify(newTemplate)); newTemplate.pasteCode = pasteCode; newTemplate.syncStatus = 'synced'; newTemplate.lastSynced = now; } catch (error) { console.error('Failed to create cloud template:', error); newTemplate.syncStatus = 'failed'; } } templates.push(newTemplate); } saveTemplates(templates); return templates[idx !== -1 ? idx : templates.length - 1]; } function getTemplateByName(name) { return getTemplates().find(t => t.name === name); } async function removeTemplate(name) { let templates = getTemplates(); const template = templates.find(t => t.name === name); if (template && template.pasteCode && isPastebinConfigured()) { try { await deletePastebinPaste(template.pasteCode); } catch (error) { console.error('Failed to delete cloud template:', error); } } templates = templates.filter(t => t.name !== name); saveTemplates(templates); } // --- PHRASE CLOUD SYNC FUNCTIONS --- async function syncPhrasesToCloud() { if (!isPastebinConfigured()) { throw new Error('Pastebin API not configured'); } const phrases = getPhrases(); if (phrases.length === 0) { console.log('No phrases to sync'); return { success: true, message: 'No phrases to sync' }; } // Create aggregated phrases data const phrasesData = { version: '1.0', lastUpdated: new Date().toISOString(), phrases: phrases.map(p => ({ name: p.name, text: p.text, lastModified: p.lastModified || Date.now() })) }; try { // Check if phrases paste already exists const existingPhrasesPaste = await findPhrasesPaste(); if (existingPhrasesPaste) { // Pastebin API doesn't support updates, so we must delete and recreate console.log('Deleting existing phrases paste and recreating...'); try { await deletePastebinPaste(existingPhrasesPaste.key); console.log('Successfully deleted old phrases paste'); } catch (deleteError) { console.warn('Failed to delete old phrases paste, continuing with recreation:', deleteError); } } // Create new paste (either first time or after deletion) const pasteCode = await createPastebinPaste('Amazon Review Phrases', JSON.stringify(phrasesData, null, 2)); console.log('Created new phrases paste with key:', pasteCode); // Mark all phrases as synced phrases.forEach(phrase => phrase.syncStatus = 'synced'); savePhrases(phrases); return { success: true, message: 'Phrases synced successfully' }; } catch (error) { console.error('Failed to sync phrases:', error); // Mark phrases as failed phrases.forEach(phrase => { if (phrase.syncStatus === 'pending') { phrase.syncStatus = 'failed'; } }); savePhrases(phrases); throw error; } } async function findPhrasesPaste() { try { const pastes = await listUserPastes(); return pastes.find(paste => paste.type === 'template' && paste.title === 'Amazon Review Phrases' ); } catch (error) { console.error('Error finding phrases paste:', error); return null; } } async function syncPhrasesFromCloud() { if (!isPastebinConfigured() || !PASTEBIN_CONFIG.API_USER_KEY) { throw new Error('Pastebin API not configured or user key missing'); } try { const phrasesPaste = await findPhrasesPaste(); if (!phrasesPaste) { return { imported: 0, updated: 0, message: 'No phrases found in cloud' }; } const content = await getPastebinPaste(phrasesPaste.key); const phrasesData = JSON.parse(content); if (!phrasesData.phrases || !Array.isArray(phrasesData.phrases)) { throw new Error('Invalid phrases data format'); } const localPhrases = getPhrases(); let importedCount = 0; let updatedCount = 0; for (const cloudPhrase of phrasesData.phrases) { const existingIndex = localPhrases.findIndex(p => p.name === cloudPhrase.name); if (existingIndex === -1) { // Import new phrase localPhrases.push({ ...cloudPhrase, syncStatus: 'synced' }); importedCount++; } else { // Update existing phrase if cloud version is newer const localPhrase = localPhrases[existingIndex]; if (cloudPhrase.lastModified > localPhrase.lastModified) { localPhrases[existingIndex] = { ...cloudPhrase, syncStatus: 'synced' }; updatedCount++; } } } savePhrases(localPhrases); return { imported: importedCount, updated: updatedCount }; } catch (error) { console.error('Failed to import phrases from cloud:', error); throw error; } } // Cloud sync functions async function syncTemplatesToCloud() { if (!isPastebinConfigured()) { throw new Error('Pastebin API not configured'); } const templates = getTemplates(); let syncedCount = 0; let failedCount = 0; console.log(`Starting sync for ${templates.length} templates...`); for (const template of templates) { try { console.log(`Processing template: ${template.name} (status: ${template.syncStatus})`); if (template.syncStatus === 'none' || template.syncStatus === 'failed') { // Create new paste console.log(`Creating new paste for: ${template.name}`); const pasteCode = await createPastebinPaste(template.name, JSON.stringify(template)); console.log(`Received paste code: ${pasteCode}`); // Validate the paste code if (!pasteCode || pasteCode.length < 8) { throw new Error(`Invalid paste code received: ${pasteCode}`); } template.pasteCode = pasteCode; template.syncStatus = 'synced'; template.lastSynced = Date.now(); syncedCount++; console.log(`Successfully created paste for: ${template.name}`); // Verify the paste was actually created const verified = await verifyPasteCreation(pasteCode); if (!verified) { console.warn(`Warning: Paste ${pasteCode} may not have been created successfully`); template.syncStatus = 'failed'; syncedCount--; failedCount++; } } else if (template.syncStatus === 'pending') { // Update existing paste (using delete + recreate since Pastebin doesn't support updates) console.log(`Updating existing paste for: ${template.name} (code: ${template.pasteCode})`); try { // Delete old paste and create new one await deletePastebinPaste(template.pasteCode); const newPasteCode = await createPastebinPaste(template.name, JSON.stringify(template)); template.pasteCode = newPasteCode; template.syncStatus = 'synced'; template.lastSynced = Date.now(); syncedCount++; console.log(`Successfully updated paste for: ${template.name}`); } catch (error) { console.error(`Failed to update paste for: ${template.name}:`, error); throw new Error('Update operation failed'); } } else if (template.syncStatus === 'synced') { console.log(`Template ${template.name} already synced, skipping`); } } catch (error) { console.error(`Failed to sync template "${template.name}":`, error); template.syncStatus = 'failed'; failedCount++; } } console.log(`Sync completed. Synced: ${syncedCount}, Failed: ${failedCount}`); saveTemplates(templates); return { synced: syncedCount, failed: failedCount }; } // Verify that pastes were actually created async function verifyPasteCreation(pasteCode) { try { // Try to fetch the paste content to verify it exists const response = await fetch(`https://pastebin.com/raw/${pasteCode}`); if (response.ok) { const content = await response.text(); console.log(`Paste ${pasteCode} verified - content length: ${content.length}`); return true; } else { console.error(`Paste ${pasteCode} verification failed - status: ${response.status}`); return false; } } catch (error) { console.error(`Paste ${pasteCode} verification error:`, error); return false; } } async function syncTemplatesFromCloud() { if (!isPastebinConfigured()) { throw new Error('Pastebin API not configured'); } if (!PASTEBIN_CONFIG.API_USER_KEY) { throw new Error('User key required for importing from cloud'); } const cloudTemplates = await listUserPastes(); const localTemplates = getTemplates(); let importedCount = 0; let updatedCount = 0; console.log(`Found ${cloudTemplates.length} total pastes in cloud:`); cloudTemplates.forEach(paste => { console.log(`- ${paste.type}: "${paste.title}"`); }); for (const cloudTemplate of cloudTemplates) { // Only process template pastes (not review pastes) if (cloudTemplate.type !== 'template') { console.log(`Skipping non-template paste: "${cloudTemplate.title}" (type: ${cloudTemplate.type})`); continue; } console.log(`Processing template: "${cloudTemplate.title}"`); try { const content = await getPastebinPaste(cloudTemplate.key); const templateData = JSON.parse(content); console.log(`Template data:`, templateData); const existingIndex = localTemplates.findIndex(t => t.name === templateData.name); if (existingIndex === -1) { // Import new template const newTemplate = { ...templateData, pasteCode: cloudTemplate.key, syncStatus: 'synced', lastSynced: Date.now() }; localTemplates.push(newTemplate); importedCount++; console.log(`Imported new template: "${templateData.name}"`); } else { // Update existing template if cloud version is newer const localTemplate = localTemplates[existingIndex]; if (templateData.lastModified > localTemplate.lastModified) { localTemplates[existingIndex] = { ...templateData, pasteCode: cloudTemplate.key, syncStatus: 'synced', lastSynced: Date.now() }; updatedCount++; console.log(`Updated existing template: "${templateData.name}"`); } else { console.log(`Template "${templateData.name}" already up to date`); } } } catch (error) { console.error(`Failed to import template "${cloudTemplate.title}":`, error); } } console.log(`Import completed. Imported: ${importedCount}, Updated: ${updatedCount}`); saveTemplates(localTemplates); return { imported: importedCount, updated: updatedCount }; } // Create Pastebin button and popover function createPastebinButton() { const pastebinBtn = document.createElement('button'); pastebinBtn.type = 'button'; pastebinBtn.innerHTML = '☁️'; pastebinBtn.title = 'Pastebin Cloud Sync'; pastebinBtn.style.fontSize = '1.1em'; pastebinBtn.style.padding = '2px 8px'; pastebinBtn.style.borderRadius = '4px'; pastebinBtn.style.border = '1px solid #bbb'; pastebinBtn.style.background = '#f8f8f8'; pastebinBtn.style.cursor = 'pointer'; pastebinBtn.style.transition = 'background 0.15s ease, color 0.15s ease, transform 0.1s ease'; pastebinBtn.style.outline = 'none'; pastebinBtn.style.userSelect = 'none'; pastebinBtn.style.position = 'relative'; // Create popover const popover = document.createElement('div'); popover.className = 'pastebin-popover'; const menuItems = [ { icon: '💾', text: 'Save Review', action: 'save-review', requiresConfig: true, requiresUserKey: true }, { icon: '📥', text: 'Fetch Review', action: 'fetch-review', requiresConfig: true, requiresUserKey: true }, { icon: '📋', text: 'Create Manual Paste', action: 'create-manual', requiresConfig: false }, { icon: '📥', text: 'Import from Paste URL', action: 'import-manual', requiresConfig: false }, { icon: '📚', text: 'Import Templates', action: 'import-templates', requiresConfig: true, requiresUserKey: true }, { icon: '🔗', text: 'My Pastebin', action: 'my-pastebin', requiresConfig: true, requiresUserKey: true }, { icon: '⚙️', text: 'API Settings', action: 'settings', requiresConfig: false }, { icon: '📊', text: 'Sync Status', action: 'status', requiresConfig: false }, { icon: '🗑️', text: 'Clear Cloud Data', action: 'clear-cloud', requiresConfig: true } ]; menuItems.forEach(item => { const menuItem = document.createElement('div'); menuItem.className = 'pastebin-popover-item'; if (item.requiresConfig && !isPastebinConfigured()) { menuItem.classList.add('disabled'); } if (item.requiresUserKey && !PASTEBIN_CONFIG.API_USER_KEY) { menuItem.classList.add('disabled'); } menuItem.innerHTML = ` <span>${item.icon}</span> <span>${item.text}</span> `; menuItem.addEventListener('click', () => { if (!menuItem.classList.contains('disabled')) { handlePastebinAction(item.action); } popover.classList.remove('show'); }); popover.appendChild(menuItem); }); pastebinBtn.appendChild(popover); // Toggle popover pastebinBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); popover.classList.toggle('show'); }); // Close popover when clicking outside document.addEventListener('click', (e) => { if (!pastebinBtn.contains(e.target)) { popover.classList.remove('show'); } }); // Close popover on ESC key document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { popover.classList.remove('show'); } }); return pastebinBtn; } // Handle Pastebin popover actions async function handlePastebinAction(action) { switch (action) { case 'save-review': await handleSaveReview(); break; case 'fetch-review': await handleFetchReview(); break; case 'sync-to-cloud': await handleSyncToCloud(); break; case 'import-from-cloud': await handleImportFromCloud(); break; case 'create-manual': await handleCreateManualPaste(); break; case 'import-manual': await handleImportManualPaste(); break; case 'import-templates': await handleImportTemplates(); break; case 'my-pastebin': handleMyPastebin(); break; case 'settings': showPastebinSettings(); break; case 'status': showSyncStatus(); break; case 'clear-cloud': await handleClearCloudData(); break; default: console.warn('Unknown Pastebin action:', action); } } // Handle sync to cloud async function handleSyncToCloud() { try { console.log('Starting sync to cloud...'); const result = await syncTemplatesToCloud(); let message = `Sync completed!\nSynced: ${result.synced}\nFailed: ${result.failed}`; if (result.failed > 0) { message += '\n\nSome templates failed to sync. Check the browser console for details.'; } if (result.synced === 0 && result.failed === 0) { message = 'No templates to sync. All templates are already up to date.'; } alert(message); refreshTemplateOptions(); } catch (error) { console.error('Sync failed:', error); alert(`Sync failed: ${error.message}\n\nTry using the manual paste method instead.`); } } // Handle import from cloud async function handleImportFromCloud() { try { const result = await syncTemplatesFromCloud(); alert(`Import completed!\nImported: ${result.imported}\nUpdated: ${result.updated}`); refreshTemplateOptions(); } catch (error) { alert(`Import failed: ${error.message}`); } } // Handle clear cloud data async function handleClearCloudData() { if (!confirm('This will delete all templates from Pastebin. Continue?')) { return; } const templates = getTemplates(); let deletedCount = 0; for (const template of templates) { if (template.pasteCode) { try { await deletePastebinPaste(template.pasteCode); template.pasteCode = null; template.syncStatus = 'none'; template.lastSynced = null; deletedCount++; } catch (error) { console.error(`Failed to delete template "${template.name}":`, error); } } } saveTemplates(templates); alert(`Cleared ${deletedCount} templates from cloud`); refreshTemplateOptions(); } // Handle manual paste creation async function handleCreateManualPaste() { const templates = getTemplates(); if (templates.length === 0) { alert('No templates to export. Please save some templates first.'); return; } // Create a combined export of all templates const exportData = { version: '1.0', exportDate: new Date().toISOString(), templates: templates.map(t => ({ name: t.name, text: t.text, height: t.height, lastModified: t.lastModified || Date.now() })) }; const exportText = JSON.stringify(exportData, null, 2); // Create a temporary textarea to copy the data const textarea = document.createElement('textarea'); textarea.value = exportText; textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; textarea.style.top = '-9999px'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); // Open Pastebin in a new tab const pastebinUrl = 'https://pastebin.com/'; window.open(pastebinUrl, '_blank'); alert(`Template data copied to clipboard!\n\nInstructions:\n1. Paste the data into Pastebin\n2. Set title to "Amazon Review Templates"\n3. Set format to "JSON"\n4. Set expiration to "Never"\n5. Click "Create New Paste"\n6. Copy the paste URL for importing later`); } // Handle manual paste import async function handleImportManualPaste() { const pasteUrl = prompt('Enter Pastebin URL (e.g., https://pastebin.com/abc123):', ''); if (!pasteUrl) return; // Extract paste key from URL const pasteKeyMatch = pasteUrl.match(/pastebin\.com\/([a-zA-Z0-9]+)/); if (!pasteKeyMatch) { alert('Invalid Pastebin URL. Please enter a valid URL like https://pastebin.com/abc123'); return; } const pasteKey = pasteKeyMatch[1]; try { // Try to fetch the paste content const response = await fetch(`https://pastebin.com/raw/${pasteKey}`); if (!response.ok) { throw new Error('Failed to fetch paste content'); } const content = await response.text(); const importData = JSON.parse(content); if (!importData.templates || !Array.isArray(importData.templates)) { throw new Error('Invalid template data format'); } const localTemplates = getTemplates(); let importedCount = 0; let updatedCount = 0; for (const templateData of importData.templates) { const existingIndex = localTemplates.findIndex(t => t.name === templateData.name); if (existingIndex === -1) { // Import new template const newTemplate = { ...templateData, syncStatus: 'none', pasteCode: null, lastSynced: null }; localTemplates.push(newTemplate); importedCount++; } else { // Update existing template if import version is newer const localTemplate = localTemplates[existingIndex]; if (templateData.lastModified > localTemplate.lastModified) { localTemplates[existingIndex] = { ...templateData, syncStatus: 'none', pasteCode: null, lastSynced: null }; updatedCount++; } } } saveTemplates(localTemplates); refreshTemplateOptions(); alert(`Import completed!\nImported: ${importedCount}\nUpdated: ${updatedCount}`); } catch (error) { console.error('Import failed:', error); alert(`Import failed: ${error.message}\n\nMake sure the paste contains valid template data in JSON format.`); } } // Show sync status function showSyncStatus() { const templates = getTemplates(); let statusText = 'Template Sync Status:\n\n'; if (templates.length === 0) { statusText += 'No templates found.'; } else { templates.forEach(template => { const statusIcon = { 'synced': '🟢', 'pending': '🟡', 'failed': '🔴', 'none': '⚪' }[template.syncStatus] || '⚪'; statusText += `${statusIcon} ${template.name}\n`; }); } alert(statusText); } // Handle save review action async function handleSaveReview() { const textarea = document.getElementById('reviewText'); if (!textarea) { alert('Review textarea not found'); return; } if (!textarea.value.trim()) { alert('Please enter some review text before saving to cloud'); return; } try { const result = await saveReviewToCloud(textarea); alert(`${result.message}\n\nPaste URL: ${result.pasteUrl}`); } catch (error) { console.error('Save review failed:', error); alert(`Failed to save review: ${error.message}`); } } // Handle fetch review action async function handleFetchReview() { const textarea = document.getElementById('reviewText'); if (!textarea) { alert('Review textarea not found'); return; } // Check if textarea has content and warn user if (textarea.value.trim()) { if (!confirm('This will replace your current review text. Continue?')) { return; } } try { const result = await fetchReviewFromCloud(textarea); alert(`${result.message}\n\nPaste URL: ${result.pasteUrl}`); } catch (error) { console.error('Fetch review failed:', error); alert(`Failed to fetch review: ${error.message}`); } } // Handle import templates action (same as previous "Import from Cloud") async function handleImportTemplates() { try { await handleImportFromCloud(); } catch (error) { console.error('Import templates failed:', error); alert(`Failed to import templates: ${error.message}`); } } // Handle My Pastebin action function handleMyPastebin() { // Extract username from the user key or use a default approach // Since we can't directly get the username from the API, we'll use a generic approach const pastebinUrl = 'https://pastebin.com/u/'; window.open(pastebinUrl, '_blank'); // Show a helpful message alert('Opening Pastebin user page.\n\nNote: You may need to log in to see your pastes. The URL format is typically:\nhttps://pastebin.com/u/[username]\n\nYou can find your username in your Pastebin account settings.'); } // Show Pastebin settings modal function showPastebinSettings() { // Create modal if it doesn't exist let modal = document.querySelector('.pastebin-modal'); if (!modal) { modal = document.createElement('div'); modal.className = 'pastebin-modal'; modal.innerHTML = ` <div class="pastebin-modal-content"> <div class="pastebin-modal-header"> <h3 class="pastebin-modal-title">Pastebin API Settings</h3> <button class="pastebin-modal-close">×</button> </div> <form id="pastebin-settings-form"> <div class="pastebin-form-group"> <label class="pastebin-form-label" for="api-dev-key">API Dev Key *</label> <input type="text" id="api-dev-key" class="pastebin-form-input" placeholder="Enter your Pastebin API Dev Key" required> <div class="pastebin-form-help"> Get your API Dev Key from <a href="https://pastebin.com/doc_api" target="_blank">Pastebin API documentation</a> </div> </div> <div class="pastebin-form-group"> <label class="pastebin-form-label" for="api-username">Pastebin Username</label> <input type="text" id="api-username" class="pastebin-form-input" placeholder="Enter your Pastebin username"> <div class="pastebin-form-help"> Your Pastebin account username (required to post under your account) </div> </div> <div class="pastebin-form-group"> <label class="pastebin-form-label" for="api-password">Pastebin Password</label> <input type="password" id="api-password" class="pastebin-form-input" placeholder="Enter your Pastebin password"> <div class="pastebin-form-help"> Your Pastebin account password (will be used to generate User Key) </div> </div> <div class="pastebin-form-group"> <label class="pastebin-form-label" for="api-user-key">API User Key (Auto-generated)</label> <input type="text" id="api-user-key" class="pastebin-form-input" placeholder="Will be generated automatically" readonly> <div class="pastebin-form-help"> This will be automatically generated from your username/password </div> </div> <div class="pastebin-form-actions"> <button type="button" class="pastebin-btn" id="generate-user-key">Generate User Key</button> <button type="button" class="pastebin-btn" id="test-connection">Test Connection</button> <button type="button" class="pastebin-btn" id="cancel-settings">Cancel</button> <button type="submit" class="pastebin-btn pastebin-btn-primary">Save Settings</button> </div> </form> </div> `; document.body.appendChild(modal); // Add event listeners const closeBtn = modal.querySelector('.pastebin-modal-close'); const cancelBtn = modal.querySelector('#cancel-settings'); const testBtn = modal.querySelector('#test-connection'); const generateBtn = modal.querySelector('#generate-user-key'); const form = modal.querySelector('#pastebin-settings-form'); closeBtn.addEventListener('click', () => modal.classList.remove('show')); cancelBtn.addEventListener('click', () => modal.classList.remove('show')); generateBtn.addEventListener('click', async () => { const devKey = modal.querySelector('#api-dev-key').value; const username = modal.querySelector('#api-username').value; const password = modal.querySelector('#api-password').value; if (!devKey) { alert('Please enter an API Dev Key first.'); return; } if (!username || !password) { alert('Please enter both username and password to generate User Key.'); return; } generateBtn.disabled = true; generateBtn.innerHTML = '<span class="pastebin-loading"></span> Generating...'; try { // Temporarily set dev key for generation const originalDevKey = PASTEBIN_CONFIG.API_DEV_KEY; PASTEBIN_CONFIG.API_DEV_KEY = devKey; const userKey = await generatePastebinUserKey(username, password); // Update the user key field modal.querySelector('#api-user-key').value = userKey; // Automatically save the configuration PASTEBIN_CONFIG.API_DEV_KEY = devKey; PASTEBIN_CONFIG.API_USER_NAME = username; PASTEBIN_CONFIG.API_USER_PASSWORD = password; PASTEBIN_CONFIG.API_USER_KEY = userKey; savePastebinConfig(); alert('User Key generated and saved successfully!'); } catch (error) { alert(`Failed to generate User Key: ${error.message}`); } finally { generateBtn.disabled = false; generateBtn.textContent = 'Generate User Key'; } }); testBtn.addEventListener('click', async () => { const devKey = modal.querySelector('#api-dev-key').value; const userKey = modal.querySelector('#api-user-key').value; if (!devKey) { alert('Please enter an API Dev Key first.'); return; } testBtn.disabled = true; testBtn.innerHTML = '<span class="pastebin-loading"></span> Testing...'; try { // Temporarily set keys for testing const originalDevKey = PASTEBIN_CONFIG.API_DEV_KEY; const originalUserKey = PASTEBIN_CONFIG.API_USER_KEY; PASTEBIN_CONFIG.API_DEV_KEY = devKey; PASTEBIN_CONFIG.API_USER_KEY = userKey; const result = await testPastebinConnection(); if (result.success) { alert('Connection successful!'); } else { alert(`Connection failed: ${result.message}`); } // Restore original keys PASTEBIN_CONFIG.API_DEV_KEY = originalDevKey; PASTEBIN_CONFIG.API_USER_KEY = originalUserKey; } catch (error) { alert(`Test failed: ${error.message}`); } finally { testBtn.disabled = false; testBtn.textContent = 'Test Connection'; } }); form.addEventListener('submit', (e) => { e.preventDefault(); const devKey = modal.querySelector('#api-dev-key').value; const username = modal.querySelector('#api-username').value; const password = modal.querySelector('#api-password').value; const userKey = modal.querySelector('#api-user-key').value; PASTEBIN_CONFIG.API_DEV_KEY = devKey || null; PASTEBIN_CONFIG.API_USER_NAME = username || null; PASTEBIN_CONFIG.API_USER_PASSWORD = password || null; PASTEBIN_CONFIG.API_USER_KEY = userKey || null; savePastebinConfig(); modal.classList.remove('show'); alert('Settings saved!'); }); // Close modal when clicking outside modal.addEventListener('click', (e) => { if (e.target === modal) { modal.classList.remove('show'); } }); } // Populate current values const devKeyInput = modal.querySelector('#api-dev-key'); const usernameInput = modal.querySelector('#api-username'); const passwordInput = modal.querySelector('#api-password'); const userKeyInput = modal.querySelector('#api-user-key'); devKeyInput.value = PASTEBIN_CONFIG.API_DEV_KEY || ''; usernameInput.value = PASTEBIN_CONFIG.API_USER_NAME || ''; passwordInput.value = PASTEBIN_CONFIG.API_USER_PASSWORD || ''; userKeyInput.value = PASTEBIN_CONFIG.API_USER_KEY || ''; // Show modal modal.classList.add('show'); } // Template and Phrase UI Container const templateContainer = document.createElement('div'); templateContainer.style.marginLeft = ''; templateContainer.style.display = 'flex'; templateContainer.style.alignItems = 'center'; templateContainer.style.gap = '6px'; // Phrase dropdown const phraseSelect = document.createElement('select'); phraseSelect.style.maxWidth = '150px'; phraseSelect.style.fontSize = '1em'; phraseSelect.style.padding = '2px 6px'; phraseSelect.style.borderRadius = '4px'; phraseSelect.style.border = '1px solid #bbb'; phraseSelect.style.background = '#fff'; phraseSelect.style.color = '#222'; phraseSelect.title = 'Insert a saved phrase'; function refreshPhraseOptions() { const phrases = getPhrases(); phraseSelect.innerHTML = ''; const defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.textContent = 'Insert phrase...'; phraseSelect.appendChild(defaultOpt); phrases.forEach(p => { const opt = document.createElement('option'); opt.value = p.name; // Add sync status indicator const statusIcon = { 'synced': '🟢', 'pending': '🟡', 'failed': '🔴', 'none': '⚪' }[p.syncStatus] || '⚪'; opt.textContent = `${statusIcon} ${p.name}`; phraseSelect.appendChild(opt); }); } refreshPhraseOptions(); // Insert phrase on select phraseSelect.addEventListener('change', function() { const name = phraseSelect.value; if (!name) return; const phrase = getPhraseByName(name); if (!phrase) return; // Insert phrase at cursor position const cursorPos = textarea.selectionStart; const textBefore = textarea.value.substring(0, cursorPos); const textAfter = textarea.value.substring(textarea.selectionEnd); textarea.value = textBefore + phrase.text + textAfter; // Move cursor to end of inserted phrase const newCursorPos = cursorPos + phrase.text.length; textarea.setSelectionRange(newCursorPos, newCursorPos); // Trigger input event for autosave textarea.dispatchEvent(new Event('input', { bubbles: true })); phraseSelect.value = ''; textarea.focus(); }); const templateSelect = document.createElement('select'); templateSelect.style.maxWidth = '180px'; templateSelect.style.fontSize = '1em'; templateSelect.style.padding = '2px 6px'; templateSelect.style.borderRadius = '4px'; templateSelect.style.border = '1px solid #bbb'; templateSelect.style.background = '#fff'; // Ensure default background templateSelect.style.color = '#222'; // Ensure default text color templateSelect.title = 'Insert a saved template'; function refreshTemplateOptions() { const templates = getTemplates(); templateSelect.innerHTML = ''; const defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.textContent = 'Insert template...'; templateSelect.appendChild(defaultOpt); templates.forEach(t => { const opt = document.createElement('option'); opt.value = t.name; // Add sync status indicator const statusIcon = { 'synced': '🟢', 'pending': '🟡', 'failed': '🔴', 'none': '⚪' }[t.syncStatus] || '⚪'; opt.textContent = `${statusIcon} ${t.name}`; templateSelect.appendChild(opt); }); } refreshTemplateOptions(); // Insert template on select templateSelect.addEventListener('change', function() { const name = templateSelect.value; if (!name) return; const template = getTemplateByName(name); if (!template) return; // Confirm if textbox is not empty and would overwrite if (textarea.value && textarea.value !== template.text) { if (!confirm('Replace current text with template "' + name + '"?')) { templateSelect.value = ''; return; } } textarea.value = template.text; if (template.height) textarea.style.height = template.height; // Trigger input event for autosave textarea.dispatchEvent(new Event('input', { bubbles: true })); templateSelect.value = ''; }); // Save as template button const saveTemplateBtn = document.createElement('button'); saveTemplateBtn.type = 'button'; saveTemplateBtn.textContent = 'Save as...'; saveTemplateBtn.style.fontSize = '1em'; saveTemplateBtn.style.padding = '2px 8px'; saveTemplateBtn.style.borderRadius = '4px'; saveTemplateBtn.style.border = '1px solid #bbb'; saveTemplateBtn.style.background = '#f8f8f8'; saveTemplateBtn.style.color = '#222'; // Normal text color saveTemplateBtn.style.opacity = '1'; // Not faded saveTemplateBtn.style.cursor = 'pointer'; saveTemplateBtn.style.transition = 'background 0.15s, color 0.15s, transform 0.1s'; saveTemplateBtn.style.userSelect = 'none'; saveTemplateBtn.title = 'Save current text as a reusable template'; saveTemplateBtn.addEventListener('mouseenter', () => { saveTemplateBtn.style.background = '#e8e8e8'; }); saveTemplateBtn.addEventListener('mouseleave', () => { saveTemplateBtn.style.background = '#f8f8f8'; }); saveTemplateBtn.addEventListener('click', async function() { // Smart detection: selected text = phrase, no selection = template const hasSelection = textarea.selectionStart !== textarea.selectionEnd; const selectedText = hasSelection ? textarea.value.substring(textarea.selectionStart, textarea.selectionEnd) : ''; if (hasSelection && selectedText.trim()) { // Save as phrase let name = prompt('Phrase name:', ''); if (!name) return; name = name.trim(); if (!name) return; // Check if phrase exists const exists = !!getPhraseByName(name); if (exists && !confirm('A phrase with this name exists. Overwrite?')) return; // Show loading state const originalText = saveTemplateBtn.textContent; saveTemplateBtn.textContent = 'Saving phrase...'; saveTemplateBtn.disabled = true; try { await upsertPhrase(name, selectedText); refreshPhraseOptions(); alert('Phrase saved!'); } catch (error) { alert(`Failed to save phrase: ${error.message}`); } finally { saveTemplateBtn.textContent = originalText; saveTemplateBtn.disabled = false; } } else { // Save as template let name = prompt('Template name:', ''); if (!name) return; name = name.trim(); if (!name) return; // Check if template exists const exists = !!getTemplateByName(name); if (exists && !confirm('A template with this name exists. Overwrite?')) return; // Show loading state const originalText = saveTemplateBtn.textContent; saveTemplateBtn.textContent = 'Saving template...'; saveTemplateBtn.disabled = true; try { await upsertTemplate(name, textarea.value, textarea.style.height); refreshTemplateOptions(); alert('Template saved!'); } catch (error) { alert(`Failed to save template: ${error.message}`); } finally { saveTemplateBtn.textContent = originalText; saveTemplateBtn.disabled = false; } } }); // Remove template button (optional, for user convenience) const deleteTemplateBtn = document.createElement('button'); deleteTemplateBtn.type = 'button'; deleteTemplateBtn.textContent = 'Manage'; deleteTemplateBtn.style.fontSize = '1em'; deleteTemplateBtn.style.padding = '2px 8px'; deleteTemplateBtn.style.borderRadius = '4px'; deleteTemplateBtn.style.border = '1px solid #bbb'; deleteTemplateBtn.style.background = '#f8f8f8'; deleteTemplateBtn.style.color = '#222'; deleteTemplateBtn.style.cursor = 'pointer'; deleteTemplateBtn.style.transition = 'background 0.15s, color 0.15s, transform 0.1s'; deleteTemplateBtn.style.userSelect = 'none'; deleteTemplateBtn.title = 'Manage templates (view, delete, etc.)'; deleteTemplateBtn.addEventListener('mouseenter', () => { deleteTemplateBtn.style.background = '#e8e8e8'; }); deleteTemplateBtn.addEventListener('mouseleave', () => { deleteTemplateBtn.style.background = '#f8f8f8'; }); deleteTemplateBtn.addEventListener('click', function() { showTemplateManager(); }); // Unified Template and Phrase Manager function showTemplateManager() { const templates = getTemplates(); const phrases = getPhrases(); // Create modal if it doesn't exist let modal = document.querySelector('.template-manager-modal'); if (!modal) { modal = document.createElement('div'); modal.className = 'template-manager-modal'; modal.innerHTML = ` <div class="template-manager-content"> <div class="template-manager-header"> <h3 class="template-manager-title">Templates & Phrases Manager</h3> <button class="template-manager-close">×</button> </div> <div class="template-manager-tabs"> <button class="tab-btn active" data-tab="templates">📝 Templates</button> <button class="tab-btn" data-tab="phrases">💬 Phrases</button> </div> <div class="template-manager-body"> <div class="tab-content" id="templates-tab"> <div class="template-list" id="template-list"> <!-- Templates will be populated here --> </div> </div> <div class="tab-content" id="phrases-tab" style="display: none;"> <div class="template-list" id="phrase-list"> <!-- Phrases will be populated here --> </div> </div> </div> </div> `; document.body.appendChild(modal); // Add tab switching functionality const tabBtns = modal.querySelectorAll('.tab-btn'); const tabContents = modal.querySelectorAll('.tab-content'); tabBtns.forEach(btn => { btn.addEventListener('click', () => { const tabName = btn.dataset.tab; // Update active tab button tabBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); // Show corresponding tab content tabContents.forEach(content => { if (content.id === `${tabName}-tab`) { content.style.display = 'block'; } else { content.style.display = 'none'; } }); }); }); // Add event listeners const closeBtn = modal.querySelector('.template-manager-close'); closeBtn.addEventListener('click', () => modal.classList.remove('show')); // Close modal when clicking outside modal.addEventListener('click', (e) => { if (e.target === modal) { modal.classList.remove('show'); } }); } // Populate template list const templateList = modal.querySelector('#template-list'); templateList.innerHTML = ''; if (templates.length === 0) { templateList.innerHTML = '<div class="no-templates">No templates found. Create some templates to see them here.</div>'; } else { templates.forEach(template => { const templateItem = document.createElement('div'); templateItem.className = 'template-item'; const statusIcon = { 'synced': '🟢', 'pending': '🟡', 'failed': '🔴', 'none': '⚪' }[template.syncStatus] || '⚪'; templateItem.innerHTML = ` <div class="template-info"> <div class="template-name">${statusIcon} ${template.name}</div> <div class="template-preview">${template.text.substring(0, 100)}${template.text.length > 100 ? '...' : ''}</div> </div> <div class="template-actions"> <button class="template-btn template-insert-btn" title="Insert template">📝</button> <button class="template-btn template-delete-btn" title="Delete template">🗑</button> </div> `; // Add event listeners const insertBtn = templateItem.querySelector('.template-insert-btn'); const deleteBtn = templateItem.querySelector('.template-delete-btn'); insertBtn.addEventListener('click', () => { // Confirm if textbox is not empty and would overwrite if (textarea.value && textarea.value !== template.text) { if (!confirm('Replace current text with template "' + template.name + '"?')) { return; } } textarea.value = template.text; if (template.height) textarea.style.height = template.height; // Trigger input event for autosave textarea.dispatchEvent(new Event('input', { bubbles: true })); modal.classList.remove('show'); }); deleteBtn.addEventListener('click', async () => { if (!confirm(`Delete template "${template.name}"?`)) return; deleteBtn.disabled = true; deleteBtn.textContent = '...'; try { await removeTemplate(template.name); // Remove the item from the list templateItem.remove(); // Update the dropdown refreshTemplateOptions(); // If no templates left, show the no templates message if (templateList.children.length === 0) { templateList.innerHTML = '<div class="no-templates">No templates found. Create some templates to see them here.</div>'; } } catch (error) { alert(`Failed to delete template: ${error.message}`); deleteBtn.disabled = false; deleteBtn.textContent = '🗑'; } }); templateList.appendChild(templateItem); }); } // Populate phrase list const phraseList = modal.querySelector('#phrase-list'); phraseList.innerHTML = ''; if (phrases.length === 0) { phraseList.innerHTML = '<div class="no-templates">No phrases found. Create some phrases to see them here.</div>'; } else { phrases.forEach(phrase => { const phraseItem = document.createElement('div'); phraseItem.className = 'template-item'; const statusIcon = { 'synced': '🟢', 'pending': '🟡', 'failed': '🔴', 'none': '⚪' }[phrase.syncStatus] || '⚪'; phraseItem.innerHTML = ` <div class="template-info"> <div class="template-name">${statusIcon} ${phrase.name}</div> <div class="template-preview">${phrase.text.substring(0, 100)}${phrase.text.length > 100 ? '...' : ''}</div> </div> <div class="template-actions"> <button class="template-btn template-insert-btn" title="Insert phrase">💬</button> <button class="template-btn template-delete-btn" title="Delete phrase">🗑</button> </div> `; // Add event listeners const insertBtn = phraseItem.querySelector('.template-insert-btn'); const deleteBtn = phraseItem.querySelector('.template-delete-btn'); insertBtn.addEventListener('click', () => { // Insert phrase at cursor position const cursorPos = textarea.selectionStart; const textBefore = textarea.value.substring(0, cursorPos); const textAfter = textarea.value.substring(textarea.selectionEnd); textarea.value = textBefore + phrase.text + textAfter; // Move cursor to end of inserted phrase const newCursorPos = cursorPos + phrase.text.length; textarea.setSelectionRange(newCursorPos, newCursorPos); // Trigger input event for autosave textarea.dispatchEvent(new Event('input', { bubbles: true })); modal.classList.remove('show'); textarea.focus(); }); deleteBtn.addEventListener('click', async () => { if (!confirm(`Delete phrase "${phrase.name}"?`)) return; deleteBtn.disabled = true; deleteBtn.textContent = '...'; try { await removePhrase(phrase.name); // Remove the item from the list phraseItem.remove(); // Update the dropdown refreshPhraseOptions(); // If no phrases left, show the no phrases message if (phraseList.children.length === 0) { phraseList.innerHTML = '<div class="no-templates">No phrases found. Create some phrases to see them here.</div>'; } } catch (error) { alert(`Failed to delete phrase: ${error.message}`); deleteBtn.disabled = false; deleteBtn.textContent = '🗑'; } }); phraseList.appendChild(phraseItem); }); } // Show modal modal.classList.add('show'); } // Create Pastebin button const pastebinBtn = createPastebinButton(); templateContainer.appendChild(phraseSelect); templateContainer.appendChild(templateSelect); templateContainer.appendChild(saveTemplateBtn); templateContainer.appendChild(deleteTemplateBtn); templateContainer.appendChild(pastebinBtn); toolbar.appendChild(templateContainer); // Button definitions const buttons = [ { label: '<b>B</b>', style: 'bold', title: 'Bold Sans (𝗕)' }, { label: '<i>I</i>', style: 'italic', title: 'Italic Sans (𝘪)' }, { label: '<span style="font-family:serif">S</span>', style: 'serif', title: 'Serif (𝑠)' }, { label: '<span style="font-family:cursive">C</span>', style: 'cursive', title: 'Cursive (𝓬)' }, { label: '<sup>^</sup>', style: 'superscript', title: 'Superscript (ᵃ)' }, { label: '<u>U</u>', style: 'underline', title: 'Underline (U͟)' }, { label: '<s>S</s>', style: 'strikethrough', title: 'Strikethrough (S̶)' }, { label: '<span style="font-family:monospace">M</span>', style: 'monospace', title: 'Monospace (𝙼)' }, { label: '<span style="font-family:\'Arial Black\', Gadget, sans-serif">W</span>', style: 'wide', title: 'Wide (W)' }, { label: '●', style: 'bullet', title: 'Bullet Points (➜)' }, { label: '1.', style: 'number', title: 'Numbered List (1), 2)...)' } ]; // State for toggling let activeStyles = new Set(); const buttonElements = []; // State for bullet points and numbering let bulletMode = false; let numberMode = false; let bulletCount = 0; let numberCount = 0; let lastUsedNumber = 0; // Track the last used number for continuation // Helper function to remove specific style from text function removeStyleFromText(text, styleToRemove) { // First, detect what styles are currently applied to the text const currentStyles = detectAppliedStyles(text); // Remove the specific style we want to remove currentStyles.delete(styleToRemove); // Convert the text back to plain ASCII const plainText = convertToPlainText(text); // Reapply the remaining styles return stylize(plainText, currentStyles); } // Helper function to update button states based on selected text function updateButtonStatesFromSelection() { const cursorPos = textarea.selectionStart; const selectionEnd = textarea.selectionEnd; if (cursorPos !== selectionEnd) { // Get selected text const selected = textarea.value.slice(cursorPos, selectionEnd); const detectedStyles = detectAppliedStyles(selected); // Update activeStyles to match detected styles activeStyles.clear(); detectedStyles.forEach(style => activeStyles.add(style)); // Update button appearances buttonElements.forEach(({ element, style }) => { if (style !== 'bullet' && style !== 'number') { element.setAttribute('aria-pressed', activeStyles.has(style).toString()); } }); } } // Helper function to check if current line already has a bullet function currentLineHasBullet() { const cursorPos = textarea.selectionStart; const textBefore = textarea.value.substring(0, cursorPos); const lineStart = textBefore.lastIndexOf('\n') + 1; const lineText = textBefore.substring(lineStart); return lineText.trim().startsWith('➜'); } // Helper function to check if current line already has a number function currentLineHasNumber() { const cursorPos = textarea.selectionStart; const textBefore = textarea.value.substring(0, cursorPos); const lineStart = textBefore.lastIndexOf('\n') + 1; const lineText = textBefore.substring(lineStart); return /^\d+\)\s/.test(lineText.trim()); } // Helper function to find the highest number in the document function findHighestNumberInDocument() { const text = textarea.value; const lines = text.split('\n'); let highestNumber = 0; for (const line of lines) { const match = line.trim().match(/^(\d+)\)\s/); if (match) { const number = parseInt(match[1], 10); if (number > highestNumber) { highestNumber = number; } } } return highestNumber; } // Helper function to renumber the document when items are deleted function renumberDocument() { const text = textarea.value; const lines = text.split('\n'); let newLines = []; let currentNumber = 1; for (const line of lines) { const trimmedLine = line.trim(); const match = trimmedLine.match(/^(\d+)\)\s(.+)$/); if (match) { // This is a numbered line, renumber it const content = match[2]; newLines.push(`${currentNumber}) ${content}`); currentNumber++; } else { // This is not a numbered line, keep as is newLines.push(line); } } // Update the textarea with renumbered content const newText = newLines.join('\n'); if (newText !== text) { const cursorPos = textarea.selectionStart; const selectionEnd = textarea.selectionEnd; textarea.value = newText; // Try to maintain cursor position as much as possible const newCursorPos = Math.min(cursorPos, newText.length); const newSelectionEnd = Math.min(selectionEnd, newText.length); textarea.setSelectionRange(newCursorPos, newSelectionEnd); // Update the numbering state lastUsedNumber = currentNumber - 1; numberCount = lastUsedNumber; } } // Create buttons buttons.forEach(btn => { const button = document.createElement('button'); button.innerHTML = btn.label; button.title = btn.title; button.type = 'button'; button.style.fontSize = '1.1em'; button.style.padding = '2px 8px'; button.style.borderRadius = '4px'; button.style.border = '1px solid #bbb'; button.style.background = '#f8f8f8'; button.style.cursor = 'pointer'; button.style.transition = 'background 0.15s ease, color 0.15s ease, transform 0.1s ease'; button.style.outline = 'none'; button.style.userSelect = 'none'; button.setAttribute('aria-pressed', 'false'); // Store reference to button for efficient updates buttonElements.push({ element: button, style: btn.style }); button.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // Add immediate visual feedback button.style.transform = 'scale(0.95)'; setTimeout(() => { button.style.transform = ''; }, 100); // Handle bullet and number modes if (btn.style === 'bullet') { bulletMode = !bulletMode; if (bulletMode) { numberMode = false; // Reset other modes activeStyles.clear(); buttonElements.forEach(({ element, style }) => { if (style !== 'bullet') { element.setAttribute('aria-pressed', 'false'); } }); // Check if current line already has a bullet if (!currentLineHasBullet()) { // Insert bullet at cursor position or before selected text const cursorPos = textarea.selectionStart; const selectionEnd = textarea.selectionEnd; const textBefore = textarea.value.substring(0, cursorPos); const selectedText = textarea.value.substring(cursorPos, selectionEnd); const textAfter = textarea.value.substring(selectionEnd); // Find the beginning of the current line const lineStart = textBefore.lastIndexOf('\n') + 1; const lineText = textBefore.substring(lineStart); // If we're at the start of a line or the line is empty, insert bullet if (lineStart === cursorPos || lineText.trim() === '') { const bulletText = '➜ '; textarea.value = textBefore + bulletText + selectedText + textAfter; textarea.setSelectionRange(cursorPos + bulletText.length, cursorPos + bulletText.length + selectedText.length); } else { // Insert bullet at the beginning of the current line const bulletText = '➜ '; textarea.value = textBefore.substring(0, lineStart) + bulletText + textBefore.substring(lineStart) + selectedText + textAfter; textarea.setSelectionRange(lineStart + bulletText.length, lineStart + bulletText.length + selectedText.length); } } // If line already has bullet, just activate the mode without adding another } button.setAttribute('aria-pressed', bulletMode.toString()); return; } if (btn.style === 'number') { numberMode = !numberMode; if (numberMode) { bulletMode = false; // Reset other modes activeStyles.clear(); buttonElements.forEach(({ element, style }) => { if (style !== 'number') { element.setAttribute('aria-pressed', 'false'); } }); // Find the highest number in the document to continue from const highestNumber = findHighestNumberInDocument(); // Always use the highest number found, regardless of previous lastUsedNumber lastUsedNumber = highestNumber; numberCount = lastUsedNumber; // Check if current line already has a number if (!currentLineHasNumber()) { // Insert number at cursor position or before selected text const cursorPos = textarea.selectionStart; const selectionEnd = textarea.selectionEnd; const textBefore = textarea.value.substring(0, cursorPos); const selectedText = textarea.value.substring(cursorPos, selectionEnd); const textAfter = textarea.value.substring(selectionEnd); // Find the beginning of the current line const lineStart = textBefore.lastIndexOf('\n') + 1; const lineText = textBefore.substring(lineStart); // If we're at the start of a line or the line is empty, insert number if (lineStart === cursorPos || lineText.trim() === '') { numberCount++; lastUsedNumber = numberCount; const numberText = `${numberCount}) `; textarea.value = textBefore + numberText + selectedText + textAfter; textarea.setSelectionRange(cursorPos + numberText.length, cursorPos + numberText.length + selectedText.length); } else { // Insert number at the beginning of the current line numberCount++; lastUsedNumber = numberCount; const numberText = `${numberCount}) `; textarea.value = textBefore.substring(0, lineStart) + numberText + textBefore.substring(lineStart) + selectedText + textAfter; textarea.setSelectionRange(lineStart + numberText.length, lineStart + numberText.length + selectedText.length); } } // If line already has number, just activate the mode without adding another } else { // Numbering mode is being turned off // Clear the renumbering timeout if it exists if (textarea.renumberTimeout) { clearTimeout(textarea.renumberTimeout); } } button.setAttribute('aria-pressed', numberMode.toString()); return; } // Toggle style for regular formatting buttons if (activeStyles.has(btn.style)) { // Remove style from active styles activeStyles.delete(btn.style); button.setAttribute('aria-pressed', 'false'); // Remove this style from any selected text const cursorPos = textarea.selectionStart; const selectionEnd = textarea.selectionEnd; if (cursorPos !== selectionEnd) { // Remove style from selected text const before = textarea.value.slice(0, cursorPos); const selected = textarea.value.slice(cursorPos, selectionEnd); const after = textarea.value.slice(selectionEnd); const updated = removeStyleFromText(selected, btn.style); textarea.value = before + updated + after; textarea.setSelectionRange(cursorPos, cursorPos + updated.length); // Update activeStyles to reflect what's actually applied to the text const remainingStyles = detectAppliedStyles(updated); activeStyles.clear(); remainingStyles.forEach(style => activeStyles.add(style)); // Update button states to match the remaining styles buttonElements.forEach(({ element, style }) => { if (style !== 'bullet' && style !== 'number') { element.setAttribute('aria-pressed', activeStyles.has(style).toString()); } }); } else { // Remove style from current word const text = textarea.value; const beforeCursor = text.slice(0, cursorPos); const afterCursor = text.slice(cursorPos); const wordStart = beforeCursor.search(/\S+$/); const wordEnd = afterCursor.search(/\s|$/); if (wordStart !== -1) { const start = cursorPos - (beforeCursor.length - wordStart); const end = cursorPos + (wordEnd === -1 ? afterCursor.length : wordEnd); const word = text.slice(start, end); const updated = removeStyleFromText(word, btn.style); textarea.value = text.slice(0, start) + updated + text.slice(end); textarea.setSelectionRange(start, start + updated.length); // Update activeStyles to reflect what's actually applied to the word const remainingStyles = detectAppliedStyles(updated); activeStyles.clear(); remainingStyles.forEach(style => activeStyles.add(style)); // Update button states to match the remaining styles buttonElements.forEach(({ element, style }) => { if (style !== 'bullet' && style !== 'number') { element.setAttribute('aria-pressed', activeStyles.has(style).toString()); } }); } } } else { // Add style // Superscript and other new styles are exclusive if (btn.style === 'superscript' || btn.style === 'underline' || btn.style === 'strikethrough' || btn.style === 'monospace' || btn.style === 'wide') { activeStyles.clear(); // Use stored references instead of querying DOM buttonElements.forEach(({ element, style }) => { element.setAttribute('aria-pressed', 'false'); }); } activeStyles.add(btn.style); button.setAttribute('aria-pressed', 'true'); // Apply formatting instantly to selected text or current word const cursorPos = textarea.selectionStart; const selectionEnd = textarea.selectionEnd; if (cursorPos !== selectionEnd) { // Apply to selected text const before = textarea.value.slice(0, cursorPos); const selected = textarea.value.slice(cursorPos, selectionEnd); const after = textarea.value.slice(selectionEnd); // Detect existing styles on the selected text const existingStyles = detectAppliedStyles(selected); // Combine existing styles with new active styles const combinedStyles = new Set([...existingStyles, ...activeStyles]); // Convert to plain text and reapply all styles const plainText = convertToPlainText(selected); const styled = stylize(plainText, combinedStyles); textarea.value = before + styled + after; textarea.setSelectionRange(cursorPos, cursorPos + styled.length); } else { // Apply to current word const text = textarea.value; const beforeCursor = text.slice(0, cursorPos); const afterCursor = text.slice(cursorPos); // Find word boundaries const wordStart = beforeCursor.search(/\S+$/); const wordEnd = afterCursor.search(/\s|$/); if (wordStart !== -1) { const start = cursorPos - (beforeCursor.length - wordStart); const end = cursorPos + (wordEnd === -1 ? afterCursor.length : wordEnd); const word = text.slice(start, end); // Detect existing styles on the word const existingStyles = detectAppliedStyles(word); // Combine existing styles with new active styles const combinedStyles = new Set([...existingStyles, ...activeStyles]); // Convert to plain text and reapply all styles const plainText = convertToPlainText(word); const styled = stylize(plainText, combinedStyles); textarea.value = text.slice(0, start) + styled + text.slice(end); textarea.setSelectionRange(start, start + styled.length); } } } // Don't focus textarea - let user continue working without interruption }); // Add hover effects button.addEventListener('mouseenter', () => { if (!activeStyles.has(btn.style)) { button.style.background = '#e8e8e8'; } }); button.addEventListener('mouseleave', () => { if (!activeStyles.has(btn.style)) { button.style.background = '#f8f8f8'; } }); toolbar.appendChild(button); }); // Reset button const resetBtn = document.createElement('button'); resetBtn.textContent = 'Clear styles'; resetBtn.type = 'button'; resetBtn.style.marginLeft = '6px'; resetBtn.style.background = '#ffe0b2'; // More vibrant background resetBtn.style.border = '1px solid #ff9800'; resetBtn.style.borderRadius = '4px'; resetBtn.style.cursor = 'pointer'; resetBtn.style.transition = 'background 0.15s ease, transform 0.1s ease'; resetBtn.style.userSelect = 'none'; resetBtn.style.color = '#b26a00'; // Stronger text color resetBtn.style.opacity = '1'; // Not faded resetBtn.title = 'Remove all Unicode styling from highlighted text'; resetBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // Add immediate visual feedback resetBtn.style.transform = 'scale(0.95)'; setTimeout(() => { resetBtn.style.transform = ''; }, 100); const [start, end] = [textarea.selectionStart, textarea.selectionEnd]; if (start === end) return; const before = textarea.value.slice(0, start); const selected = textarea.value.slice(start, end); const after = textarea.value.slice(end); // Remove all stylization (replace with plain ASCII) const plain = convertToPlainText(selected); textarea.value = before + plain + after; textarea.setSelectionRange(start, start + plain.length); textarea.focus(); }); // Add hover effects for reset button resetBtn.addEventListener('mouseenter', () => { resetBtn.style.background = '#ffe0b2'; }); resetBtn.addEventListener('mouseleave', () => { resetBtn.style.background = '#fff3e0'; }); toolbar.appendChild(resetBtn); // Reset numbering button const resetNumberingBtn = document.createElement('button'); resetNumberingBtn.textContent = 'Reset Numbering'; resetNumberingBtn.type = 'button'; resetNumberingBtn.style.marginLeft = '6px'; resetNumberingBtn.style.background = '#e3f2fd'; // More vibrant background resetNumberingBtn.style.border = '1px solid #2196f3'; resetNumberingBtn.style.borderRadius = '4px'; resetNumberingBtn.style.cursor = 'pointer'; resetNumberingBtn.style.transition = 'background 0.15s ease, transform 0.1s ease'; resetNumberingBtn.style.userSelect = 'none'; resetNumberingBtn.style.color = '#1565c0'; // Stronger text color resetNumberingBtn.style.opacity = '1'; // Not faded resetNumberingBtn.title = 'Start a new numbered list from 1 at the current position'; resetNumberingBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // Add immediate visual feedback resetNumberingBtn.style.transform = 'scale(0.95)'; setTimeout(() => { resetNumberingBtn.style.transform = ''; }, 100); // Reset the numbering counter so the next list starts at 1 numberCount = 0; lastUsedNumber = 0; }); // Add hover effects for reset numbering button resetNumberingBtn.addEventListener('mouseenter', () => { resetNumberingBtn.style.background = '#bbdefb'; }); resetNumberingBtn.addEventListener('mouseleave', () => { resetNumberingBtn.style.background = '#e3f2fd'; }); toolbar.appendChild(resetNumberingBtn); // Add keydown listener for bullet points and numbering textarea.addEventListener('keydown', (e) => { if (e.key === 'Enter') { // Handle bullet mode if (bulletMode) { e.preventDefault(); const cursorPos = textarea.selectionStart; const textBefore = textarea.value.substring(0, cursorPos); const textAfter = textarea.value.substring(cursorPos); // Find the beginning of the current line const lineStart = textBefore.lastIndexOf('\n') + 1; const lineText = textBefore.substring(lineStart); // Check if current line is empty or only contains bullet const isCurrentLineEmpty = lineText.trim() === '' || lineText.trim() === '➜'; if (isCurrentLineEmpty) { // Delete the bullet and disable bullet mode const newTextBefore = textBefore.substring(0, lineStart); textarea.value = newTextBefore + textAfter; textarea.setSelectionRange(lineStart, lineStart); // Disable bullet mode bulletMode = false; buttonElements.forEach(({ element, style }) => { if (style === 'bullet') { element.setAttribute('aria-pressed', 'false'); } }); } else { // Insert new bullet on next line const bulletText = '\n➜ '; textarea.value = textBefore + bulletText + textAfter; textarea.setSelectionRange(cursorPos + bulletText.length, cursorPos + bulletText.length); } return; } // Handle number mode if (numberMode) { e.preventDefault(); const cursorPos = textarea.selectionStart; const textBefore = textarea.value.substring(0, cursorPos); const textAfter = textarea.value.substring(cursorPos); // Find the beginning of the current line const lineStart = textBefore.lastIndexOf('\n') + 1; const lineText = textBefore.substring(lineStart); // Check if current line is empty or only contains number const numberPattern = /^\d+\)\s*$/; const isCurrentLineEmpty = lineText.trim() === '' || numberPattern.test(lineText.trim()); if (isCurrentLineEmpty) { // Delete the number and disable number mode const newTextBefore = textBefore.substring(0, lineStart); textarea.value = newTextBefore + textAfter; textarea.setSelectionRange(lineStart, lineStart); // Disable number mode numberMode = false; // Don't reset numberCount to preserve the sequence buttonElements.forEach(({ element, style }) => { if (style === 'number') { element.setAttribute('aria-pressed', 'false'); } }); } else { // Insert new number on next line numberCount++; lastUsedNumber = numberCount; const numberText = `\n${numberCount}) `; textarea.value = textBefore + numberText + textAfter; textarea.setSelectionRange(cursorPos + numberText.length, cursorPos + numberText.length); } return; } } }); // Add event listeners to update button states when selection changes textarea.addEventListener('mouseup', updateButtonStatesFromSelection); textarea.addEventListener('keyup', updateButtonStatesFromSelection); textarea.addEventListener('input', (e) => { updateButtonStatesFromSelection(); // If numbering mode is active, renumber the document when content changes if (numberMode) { // Use a debounced approach to avoid renumbering during typing clearTimeout(textarea.renumberTimeout); textarea.renumberTimeout = setTimeout(() => { renumberDocument(); }, 300); // Increased delay for better performance } }); return toolbar; } // --- Auto-Save Review Draft Feature --- function getCurrentASIN() { // Try to get ASIN from URL (?asin=...) const params = new URLSearchParams(window.location.search); let asin = params.get('asin'); // Fallback: extract from path or query string if (!asin && window.location.href.includes('/review/create-review')) { const asinMatch = window.location.href.match(/[?&]asin=([A-Z0-9]{10})/); if (asinMatch) asin = asinMatch[1]; } return asin; } // Fetch product title from product page using ASIN async function fetchProductTitleFromASIN(asin) { try { const productUrl = `https://www.amazon.ca/dp/${asin}`; console.log(`Fetching product title from: ${productUrl}`); // Add timeout to the fetch request const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout const response = await fetch(productUrl, { signal: controller.signal, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const html = await response.text(); // Try to find the product title in the HTML const titleSelectors = [ '#productTitle', '.a-size-large.product-title-word-break', '.product-title-word-break', 'h1[data-automation-id="title"]', '.a-size-large' ]; for (const selector of titleSelectors) { const match = html.match(new RegExp(`<[^>]*id="${selector.replace('#', '')}"[^>]*>([^<]+)</[^>]*>`, 'i')) || html.match(new RegExp(`<[^>]*class="[^"]*${selector.replace('.', '')}[^"]*"[^>]*>([^<]+)</[^>]*>`, 'i')); if (match && match[1]) { const title = match[1].trim(); if (title && title.length > 0) { console.log(`Found product title using selector "${selector}": "${title}"`); return title; } } } console.log('No product title found in product page HTML'); return null; } catch (error) { if (error.name === 'AbortError') { console.error('Fetching product title timed out after 10 seconds'); } else { console.error('Error fetching product title:', error); } return null; } } // Get product title from the current page or fetch from product page async function getProductTitle() { // First try to get title from current page const titleElement = document.getElementById('productTitle'); if (titleElement) { return titleElement.textContent.trim(); } // Fallback: try to find title in other common selectors const selectors = [ '.a-size-large.product-title-word-break', '.product-title-word-break', 'h1[data-automation-id="title"]', '.a-size-large' ]; for (const selector of selectors) { const element = document.querySelector(selector); if (element) { return element.textContent.trim(); } } // If no title found on current page, try to fetch from product page using ASIN try { const asin = getCurrentASIN(); if (asin) { console.log(`No product title found on current page, fetching from product page for ASIN: ${asin}`); const productTitle = await fetchProductTitleFromASIN(asin); if (productTitle) { console.log(`Successfully fetched product title: "${productTitle}"`); return productTitle; } } } catch (error) { console.error('Failed to fetch product title from product page:', error); } return 'Unknown'; } // Create a review paste title with format: Amazon Product: [first 5 words] — REVIEW — [ASIN] function createReviewPasteTitle(productTitle, asin) { // Clean up the product title and limit to first 8 words for readability const cleanTitle = productTitle.replace(/[^\w\s]/g, '').trim(); const words = cleanTitle.split(/\s+/).slice(0, 8).join(' '); return `Amazon Product: ${words} — REVIEW — ${asin}`; } // Find existing review paste for current ASIN async function findReviewPasteForASIN(asin) { if (!isPastebinConfigured() || !PASTEBIN_CONFIG.API_USER_KEY) { throw new Error('Pastebin API not configured or user key missing'); } try { const pastes = await listUserPastes(); const asinSuffix = ` — ${asin}`; console.log(`Looking for review paste with ASIN: ${asin}`); console.log(`Expected suffix: ${asinSuffix}`); console.log(`Found ${pastes.length} total pastes:`); pastes.forEach(paste => { console.log(`- ${paste.type}: "${paste.title}"`); }); for (const paste of pastes) { // Only look at review pastes (not templates) if (paste.type === 'review' && paste.title && paste.title.endsWith(asinSuffix)) { console.log(`Found matching review paste: "${paste.title}"`); return paste; } } console.log('No matching review paste found'); return null; // No matching paste found } catch (error) { console.error('Error finding review paste:', error); throw error; } } // Save current review to Pastebin async function saveReviewToCloud(textarea) { const asin = getCurrentASIN(); if (!asin) { throw new Error('Could not determine ASIN from current page'); } const productTitle = await getProductTitle(); const pasteTitle = createReviewPasteTitle(productTitle, asin); const reviewContent = textarea.value; // Get review title if available const reviewTitleInput = document.getElementById('reviewTitle'); const reviewTitle = reviewTitleInput ? reviewTitleInput.value.trim() : ''; if (!reviewContent.trim()) { throw new Error('Review text is empty'); } // Create JSON payload with both review body and title const reviewData = { reviewBody: reviewContent, reviewTitle: reviewTitle, asin: asin, productTitle: productTitle, savedAt: new Date().toISOString() }; // Check if we already have a paste for this ASIN const existingPaste = await findReviewPasteForASIN(asin); if (existingPaste) { // Update existing paste (using delete + recreate since Pastebin doesn't support updates) try { // Delete old paste and create new one await deletePastebinPaste(existingPaste.key); const newPasteCode = await createPastebinPaste(pasteTitle, JSON.stringify(reviewData, null, 2), true); return { success: true, message: 'Review updated in cloud', pasteKey: newPasteCode, pasteUrl: `https://pastebin.com/${newPasteCode}` }; } catch (error) { console.error('Failed to update review paste:', error); throw new Error('Failed to update review in cloud'); } } else { // Create new paste try { const pasteCode = await createPastebinPaste(pasteTitle, JSON.stringify(reviewData, null, 2), true); return { success: true, message: 'Review saved to cloud', pasteKey: pasteCode, pasteUrl: `https://pastebin.com/${pasteCode}` }; } catch (error) { console.error('Failed to create review paste:', error); throw new Error('Failed to save review to cloud'); } } } // Fetch review from Pastebin async function fetchReviewFromCloud(textarea) { const asin = getCurrentASIN(); if (!asin) { throw new Error('Could not determine ASIN from current page'); } const existingPaste = await findReviewPasteForASIN(asin); if (!existingPaste) { throw new Error('No review found in cloud for this product'); } try { const reviewContent = await getPastebinPaste(existingPaste.key); // Try to parse as JSON first (new format) let reviewData; try { reviewData = JSON.parse(reviewContent); } catch (parseError) { // Fallback to old format (plain text) console.log('Review is in old format (plain text), using as review body only'); reviewData = { reviewBody: reviewContent }; } // Set review body if (reviewData.reviewBody) { textarea.value = reviewData.reviewBody; } else { textarea.value = reviewContent; // Fallback to raw content } // Set review title if available if (reviewData.reviewTitle) { const reviewTitleInput = document.getElementById('reviewTitle'); if (reviewTitleInput) { reviewTitleInput.value = reviewData.reviewTitle; // Trigger input event for title autosave reviewTitleInput.dispatchEvent(new Event('input', { bubbles: true })); } } // Trigger input event to update any listeners textarea.dispatchEvent(new Event('input', { bubbles: true })); return { success: true, message: 'Review loaded from cloud', pasteKey: existingPaste.key, pasteUrl: `https://pastebin.com/${existingPaste.key}` }; } catch (error) { console.error('Failed to fetch review paste:', error); throw new Error('Failed to load review from cloud'); } } function getDraftKey(asin) { return asin ? `amazon_review_draft_${asin}` : null; } // --- NEW: Title draft key --- function getTitleDraftKey(asin) { return asin ? `amazon_review_title_draft_${asin}` : null; } function insertAutosaveStatusUI() { // Find the label container const label = document.querySelector('.in-context-ryp__field-label'); if (!label) return null; let status = label.querySelector('.amazon-autosave-status'); if (!status) { status = document.createElement('span'); status.className = 'amazon-autosave-status'; status.style.float = 'right'; status.style.fontSize = '0.98em'; status.style.color = '#888'; status.style.marginLeft = '12px'; status.style.fontWeight = '400'; status.style.transition = 'color 0.2s'; status.textContent = ''; label.appendChild(status); } return status; } // --- NEW: Auto-Save for Review Title --- function attachTitleAutosave() { const titleInput = document.getElementById('reviewTitle'); if (!titleInput || titleInput.dataset.amazonTitleAutosave) return; titleInput.dataset.amazonTitleAutosave = 'true'; const asin = getCurrentASIN(); const titleDraftKey = getTitleDraftKey(asin); // Find the label for the title const label = titleInput.closest('div').querySelector('.in-context-ryp__field-label'); let statusUI = null; if (label) { statusUI = label.querySelector('.amazon-autosave-status-title'); if (!statusUI) { statusUI = document.createElement('span'); statusUI.className = 'amazon-autosave-status-title'; statusUI.style.float = 'right'; statusUI.style.fontSize = '0.98em'; statusUI.style.color = '#888'; statusUI.style.marginLeft = '12px'; statusUI.style.fontWeight = '400'; statusUI.style.transition = 'color 0.2s'; statusUI.textContent = ''; label.appendChild(statusUI); } } let saveTimeout = null; let lastSavedValue = ''; // Restore draft if present if (titleDraftKey) { const saved = localStorage.getItem(titleDraftKey); if (saved && !titleInput.value) { titleInput.value = saved; } } // Save on input (debounced) function saveDraft() { if (!titleDraftKey) return; localStorage.setItem(titleDraftKey, titleInput.value); lastSavedValue = titleInput.value; if (statusUI) { statusUI.textContent = 'Saved.'; statusUI.style.color = '#4caf50'; setTimeout(() => { if (statusUI.textContent === 'Saved.') statusUI.style.color = '#888'; }, 1200); } } function onInput() { if (statusUI) statusUI.textContent = 'Saving...'; if (saveTimeout) clearTimeout(saveTimeout); saveTimeout = setTimeout(saveDraft, 600); } titleInput.addEventListener('input', onInput); // Initial status if (statusUI) statusUI.textContent = ''; // Clear draft on submit (if possible) function clearDraftOnSubmit() { if (!titleDraftKey) return; localStorage.removeItem(titleDraftKey); if (statusUI) statusUI.textContent = ''; } // Try to detect submit button const form = titleInput.closest('form'); if (form) { form.addEventListener('submit', clearDraftOnSubmit); } else { // Fallback: look for a submit button and listen for click const submitBtn = document.querySelector('button[type="submit"], input[type="submit"]'); if (submitBtn) { submitBtn.addEventListener('click', clearDraftOnSubmit); } } } // --- Attach Toolbar to Review Textarea --- function attachToolbar() { const textarea = document.getElementById('reviewText'); if (!textarea || textarea.dataset.unicodeToolbar) return; textarea.dataset.unicodeToolbar = 'true'; const toolbar = createToolbar(textarea); textarea.parentNode.insertBefore(toolbar, textarea); // --- SWAP: Move template UI above label, autosave status into toolbar --- // 1. Move template UI above label const label = document.querySelector('.in-context-ryp__field-label'); if (label) { // Find the template UI in the toolbar const templateContainer = toolbar.querySelector('div'); if (templateContainer) { // Remove from toolbar and insert above label toolbar.removeChild(templateContainer); label.parentNode.insertBefore(templateContainer, label); templateContainer.style.marginLeft = ''; templateContainer.style.justifyContent = 'flex-end'; templateContainer.style.width = '100%'; } } // 2. Move autosave status into toolbar, right-aligned let statusUI = document.querySelector('.amazon-autosave-status'); if (statusUI) { // Remove from label and add to toolbar if (statusUI.parentNode) statusUI.parentNode.removeChild(statusUI); statusUI.style.float = ''; statusUI.style.marginLeft = 'auto'; statusUI.style.alignSelf = 'center'; toolbar.appendChild(statusUI); } else { // If not present, create and add to toolbar statusUI = document.createElement('span'); statusUI.className = 'amazon-autosave-status'; statusUI.style.marginLeft = 'auto'; statusUI.style.fontSize = '0.98em'; statusUI.style.color = '#888'; statusUI.style.fontWeight = '400'; statusUI.style.transition = 'color 0.2s'; statusUI.textContent = ''; toolbar.appendChild(statusUI); } // --- AUTOSAVE LOGIC --- const asin = getCurrentASIN(); const draftKey = getDraftKey(asin); let saveTimeout = null; let lastSavedValue = ''; // Restore draft if present if (draftKey) { const saved = localStorage.getItem(draftKey); if (saved && !textarea.value) { textarea.value = saved; } } // Save on input (debounced) function saveDraft() { if (!draftKey) return; localStorage.setItem(draftKey, textarea.value); lastSavedValue = textarea.value; if (statusUI) { statusUI.textContent = 'Saved.'; statusUI.style.color = '#4caf50'; setTimeout(() => { if (statusUI.textContent === 'Saved.') statusUI.style.color = '#888'; }, 1200); } } function onInput() { if (statusUI) statusUI.textContent = 'Saving...'; if (saveTimeout) clearTimeout(saveTimeout); saveTimeout = setTimeout(saveDraft, 600); } textarea.addEventListener('input', onInput); // Initial status if (statusUI) statusUI.textContent = ''; // Clear draft on submit (if possible) function clearDraftOnSubmit() { if (!draftKey) return; localStorage.removeItem(draftKey); if (statusUI) statusUI.textContent = ''; } // Try to detect submit button const form = textarea.closest('form'); if (form) { form.addEventListener('submit', clearDraftOnSubmit); } else { // Fallback: look for a submit button and listen for click const submitBtn = document.querySelector('button[type="submit"], input[type="submit"]'); if (submitBtn) { submitBtn.addEventListener('click', clearDraftOnSubmit); } } // Focus the textarea for better UX setTimeout(() => textarea.focus(), 100); } // --- Wait for textarea to appear --- function waitForTextarea() { const textarea = document.getElementById('reviewText'); if (textarea) { attachToolbar(); } else { setTimeout(waitForTextarea, 500); } } waitForTextarea(); // --- Wait for review title to appear and attach autosave --- function waitForTitleInput() { const titleInput = document.getElementById('reviewTitle'); if (titleInput) { attachTitleAutosave(); } else { setTimeout(waitForTitleInput, 500); } } waitForTitleInput(); // --- Drag-and-drop for media upload --- function enableMediaDragDrop() { const wrapper = document.querySelector('.in-context-ryp__form-field--mediaUploadInput--custom-wrapper'); const fileInput = document.querySelector('input[type="file"]#media'); if (!wrapper || !fileInput) return; // Create drag overlay text let dragText = document.createElement('div'); dragText.textContent = 'Drag & drop files here'; dragText.style.position = 'absolute'; dragText.style.top = '50%'; dragText.style.left = '50%'; dragText.style.transform = 'translate(-50%, -50%)'; dragText.style.fontSize = '1.2em'; dragText.style.fontWeight = 'bold'; dragText.style.color = '#1976d2'; dragText.style.background = 'rgba(255,255,255,0.85)'; dragText.style.padding = '12px 24px'; dragText.style.borderRadius = '8px'; dragText.style.boxShadow = '0 2px 8px rgba(33,150,243,0.08)'; dragText.style.pointerEvents = 'none'; dragText.style.zIndex = '100'; dragText.style.display = 'none'; dragText.className = 'amazon-dnd-dragtext'; wrapper.style.position = 'relative'; wrapper.appendChild(dragText); // Ensure Google Photos button exists let googleBtn = wrapper.querySelector('.google-photos-btn'); if (!googleBtn) { googleBtn = document.createElement('a'); googleBtn.href = 'https://photos.google.com/'; googleBtn.target = '_blank'; googleBtn.rel = 'noopener noreferrer'; googleBtn.className = 'google-photos-btn'; googleBtn.style.display = 'flex'; googleBtn.style.alignItems = 'center'; googleBtn.style.justifyContent = 'center'; googleBtn.style.gap = '8px'; googleBtn.style.margin = '18px 0 0 0'; googleBtn.style.padding = '8px 16px'; googleBtn.style.background = '#fff'; googleBtn.style.border = '1px solid #dadce0'; googleBtn.style.borderRadius = '6px 0 0 6px'; googleBtn.style.boxShadow = '0 1px 2px rgba(60,64,67,.08)'; googleBtn.style.fontSize = '1em'; googleBtn.style.fontWeight = '500'; googleBtn.style.color = '#444'; googleBtn.style.cursor = 'pointer'; googleBtn.style.textDecoration = 'none'; googleBtn.style.width = 'fit-content'; googleBtn.style.transition = 'background 0.15s, box-shadow 0.15s'; googleBtn.innerHTML = `<img src="https://cdn.iconscout.com/icon/free/png-256/free-google-photos-logo-icon-download-in-svg-png-gif-file-formats--new-logos-pack-icons-2476486.png" alt="Google Photos" style="width: 22px; height: 22px; margin-right: 8px; vertical-align: middle;">Add from Google Photos...`; googleBtn.addEventListener('mouseenter', () => { googleBtn.style.background = '#f1f3f4'; googleBtn.style.boxShadow = '0 2px 8px rgba(60,64,67,.13)'; }); googleBtn.addEventListener('mouseleave', () => { googleBtn.style.background = '#fff'; googleBtn.style.boxShadow = '0 1px 2px rgba(60,64,67,.08)'; }); // Prevent file upload overlay from opening when clicking Google Photos googleBtn.addEventListener('click', (e) => { e.stopPropagation(); }); } // Create a Paste from Clipboard button let pasteBtn = wrapper.querySelector('.paste-clipboard-btn'); if (!pasteBtn) { pasteBtn = document.createElement('button'); pasteBtn.type = 'button'; pasteBtn.className = 'paste-clipboard-btn'; pasteBtn.style.display = 'flex'; pasteBtn.style.alignItems = 'center'; pasteBtn.style.justifyContent = 'center'; pasteBtn.style.gap = '8px'; pasteBtn.style.margin = '18px 0 0 0'; pasteBtn.style.padding = '8px 16px'; pasteBtn.style.background = '#fff'; pasteBtn.style.border = '1px solid #dadce0'; pasteBtn.style.borderLeft = 'none'; pasteBtn.style.borderRadius = '0 6px 6px 0'; pasteBtn.style.boxShadow = '0 1px 2px rgba(60,64,67,.08)'; pasteBtn.style.fontSize = '1em'; pasteBtn.style.fontWeight = '500'; pasteBtn.style.color = '#444'; pasteBtn.style.cursor = 'pointer'; pasteBtn.style.textDecoration = 'none'; pasteBtn.style.width = 'fit-content'; pasteBtn.style.transition = 'background 0.15s, box-shadow 0.15s'; pasteBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#1976d2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:8px;"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Paste from Clipboard...`; pasteBtn.title = 'Paste image from clipboard'; pasteBtn.addEventListener('mouseenter', () => { pasteBtn.style.background = '#f1f3f4'; pasteBtn.style.boxShadow = '0 2px 8px rgba(60,64,67,.13)'; }); pasteBtn.addEventListener('mouseleave', () => { pasteBtn.style.background = '#fff'; pasteBtn.style.boxShadow = '0 1px 2px rgba(60,64,67,.08)'; }); pasteBtn.addEventListener('click', (e) => { e.stopPropagation(); // Try to trigger a paste event on the wrapper // This will only work if the user has granted clipboard permissions navigator.clipboard.read().then(items => { let foundImage = false; for (const item of items) { if (item.types.includes('image/png') || item.types.includes('image/jpeg')) { foundImage = true; item.getType(item.types.includes('image/png') ? 'image/png' : 'image/jpeg').then(blob => { const file = new File([blob], 'clipboard-image.' + (item.types.includes('image/png') ? 'png' : 'jpg'), { type: blob.type }); const dt = new DataTransfer(); dt.items.add(file); fileInput.files = dt.files; fileInput.dispatchEvent(new Event('change', { bubbles: true })); handleUploadUIUpdate(); // UI feedback: dim/blur area, hide buttons, show message wrapper.classList.add('amazon-uploading'); wrapper.classList.remove('dragover'); let pasteText = wrapper.querySelector('.amazon-dnd-pastetext'); if (!pasteText) { pasteText = document.createElement('div'); pasteText.className = 'amazon-dnd-pastetext'; pasteText.textContent = 'Image pasted! Uploading...'; pasteText.style.position = 'absolute'; pasteText.style.top = '50%'; pasteText.style.left = '50%'; pasteText.style.transform = 'translate(-50%, -50%)'; pasteText.style.fontSize = '1.2em'; pasteText.style.fontWeight = 'bold'; pasteText.style.color = '#388e3c'; pasteText.style.background = 'rgba(255,255,255,0.98)'; pasteText.style.padding = '16px 32px'; pasteText.style.borderRadius = '12px'; pasteText.style.boxShadow = '0 2px 12px rgba(56,142,60,0.10)'; pasteText.style.pointerEvents = 'none'; pasteText.style.zIndex = '101'; pasteText.style.display = 'block'; pasteText.style.textAlign = 'center'; wrapper.appendChild(pasteText); } else { pasteText.style.display = 'block'; } setTimeout(() => { wrapper.classList.remove('amazon-uploading'); if (pasteText) pasteText.style.display = 'none'; }, 2000); }).catch(error => { console.error('Error processing clipboard image:', error); alert('Error processing clipboard image. Please try copying the image again.'); }); break; } } if (!foundImage) { alert('No image found in clipboard! Please copy an image first.'); } }).catch(error => { console.error('Clipboard access error:', error); if (error.name === 'NotAllowedError') { alert('Clipboard access denied. Please grant clipboard permissions and try again.'); } else if (error.name === 'NotSupportedError') { alert('Clipboard API not supported in this browser. Try using Ctrl+V instead.'); } else { alert('Clipboard access failed. Please try using Ctrl+V to paste the image.'); } }); }); } // Find the outer upload container const mediaUploadInputWrapper = document.querySelector('.in-context-ryp__form-field--mediaUploadInput'); // Ensure a container for both buttons, and insert both as a group let btnGroup = document.querySelector('.amazon-btn-group'); if (!btnGroup) { btnGroup = document.createElement('div'); btnGroup.className = 'amazon-btn-group'; btnGroup.style.display = 'flex'; btnGroup.style.flexDirection = 'row'; btnGroup.style.gap = '0'; btnGroup.style.margin = '24px auto 0 auto'; btnGroup.style.width = 'fit-content'; btnGroup.style.justifyContent = 'center'; } // Ensure both buttons are in the group if (!btnGroup.contains(googleBtn)) btnGroup.appendChild(googleBtn); if (!btnGroup.contains(pasteBtn)) btnGroup.appendChild(pasteBtn); // Ensure group is in the correct place: after the upload area wrapper if (mediaUploadInputWrapper && mediaUploadInputWrapper.nextSibling !== btnGroup) { mediaUploadInputWrapper.parentNode.insertBefore(btnGroup, mediaUploadInputWrapper.nextSibling); } // Helper to always keep the button group after the upload area function repositionGooglePhotosBtn() { if (mediaUploadInputWrapper && mediaUploadInputWrapper.nextSibling !== btnGroup) { mediaUploadInputWrapper.parentNode.insertBefore(btnGroup, mediaUploadInputWrapper.nextSibling); } } // Call after each upload (drop or paste) function handleUploadUIUpdate() { setTimeout(repositionGooglePhotosBtn, 100); // Wait for DOM update } // Prevent default drag behaviors ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { wrapper.addEventListener(eventName, e => e.preventDefault()); }); // Highlight on dragover wrapper.addEventListener('dragover', () => { wrapper.classList.add('dragover'); dragText.style.display = 'block'; }); wrapper.addEventListener('dragenter', () => { wrapper.classList.add('dragover'); dragText.style.display = 'block'; }); wrapper.addEventListener('dragleave', (e) => { if (!wrapper.contains(e.relatedTarget)) { wrapper.classList.remove('dragover'); dragText.style.display = 'none'; } }); // Upload files one by one async function uploadFilesQueue(files) { for (let i = 0; i < files.length; i++) { try { // Assign file to input const dt = new DataTransfer(); dt.items.add(files[i]); fileInput.files = dt.files; fileInput.dispatchEvent(new Event('change', { bubbles: true })); // Wait before uploading the next file if (i < files.length - 1) { await new Promise(res => setTimeout(res, 2000)); } } catch (error) { console.error(`Error uploading file ${i + 1}:`, error); // Continue with next file instead of stopping the entire queue } } } wrapper.addEventListener('drop', (e) => { wrapper.classList.remove('dragover'); dragText.style.display = 'none'; if (!e.dataTransfer || !e.dataTransfer.files || e.dataTransfer.files.length === 0) return; const files = Array.from(e.dataTransfer.files); if (files.length === 1) { // Single file: upload as normal const dt = new DataTransfer(); dt.items.add(files[0]); fileInput.files = dt.files; fileInput.dispatchEvent(new Event('change', { bubbles: true })); handleUploadUIUpdate(); } else { // Multiple files: upload one by one as a queue uploadFilesQueue(files).then(handleUploadUIUpdate); } }); // --- Clipboard paste support for images --- wrapper.addEventListener('paste', (e) => { if (!e.clipboardData || !e.clipboardData.items) return; let foundImage = false; for (let i = 0; i < e.clipboardData.items.length; i++) { const item = e.clipboardData.items[i]; if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { foundImage = true; // Assign file to input and trigger upload const dt = new DataTransfer(); dt.items.add(file); fileInput.files = dt.files; fileInput.dispatchEvent(new Event('change', { bubbles: true })); e.preventDefault(); handleUploadUIUpdate(); break; } } } if (foundImage) { // UI feedback: dim/blur area, hide buttons, show message wrapper.classList.add('amazon-uploading'); wrapper.classList.remove('dragover'); let pasteText = wrapper.querySelector('.amazon-dnd-pastetext'); if (!pasteText) { pasteText = document.createElement('div'); pasteText.className = 'amazon-dnd-pastetext'; pasteText.textContent = 'Image pasted! Uploading...'; pasteText.style.position = 'absolute'; pasteText.style.top = '50%'; pasteText.style.left = '50%'; pasteText.style.transform = 'translate(-50%, -50%)'; pasteText.style.fontSize = '1.2em'; pasteText.style.fontWeight = 'bold'; pasteText.style.color = '#388e3c'; pasteText.style.background = 'rgba(255,255,255,0.98)'; pasteText.style.padding = '16px 32px'; pasteText.style.borderRadius = '12px'; pasteText.style.boxShadow = '0 2px 12px rgba(56,142,60,0.10)'; pasteText.style.pointerEvents = 'none'; pasteText.style.zIndex = '101'; pasteText.style.display = 'block'; pasteText.style.textAlign = 'center'; wrapper.appendChild(pasteText); } else { pasteText.style.display = 'block'; } // Remove feedback after 2 seconds setTimeout(() => { wrapper.classList.remove('amazon-uploading'); if (pasteText) pasteText.style.display = 'none'; }, 2000); } }); } // Wait for the media upload area to appear and enable drag-and-drop function waitForMediaUploadArea() { const wrapper = document.querySelector('.in-context-ryp__form-field--mediaUploadInput--custom-wrapper'); const fileInput = document.querySelector('input[type="file"]#media'); if (wrapper && fileInput) { // Prevent duplicate overlays if (!wrapper.querySelector('.amazon-dnd-dragtext')) { enableMediaDragDrop(); } } else { setTimeout(waitForMediaUploadArea, 500); } } waitForMediaUploadArea(); // --- Link all review candidate images to their product pages using ASIN from their review URLs --- function linkAllReviewCandidateImages() { const candidates = document.querySelectorAll('.ryp__review-candidate'); candidates.forEach(candidate => { // Find the review link with ?asin=... - handle both URL patterns const reviewLink = candidate.querySelector('a[href*="/review/"]'); if (!reviewLink) return; const url = new URL(reviewLink.href, window.location.origin); let asin = new URLSearchParams(url.search).get('asin'); // If no ASIN in query params, try to extract from path (for create-review URLs) if (!asin && url.href.includes('/review/create-review')) { const asinMatch = url.href.match(/[?&]asin=([A-Z0-9]{10})/); if (asinMatch) { asin = asinMatch[1]; } } if (!asin) return; // Find the product image const img = candidate.querySelector('img.ryp__review-candidate__product-image'); if (!img) return; // Check if already wrapped in a link to /dp/ if (img.parentElement && img.parentElement.tagName === 'A' && img.parentElement.href.includes('/dp/')) return; // Create the product link const link = document.createElement('a'); link.href = `https://www.amazon.ca/dp/${asin}`; link.target = '_blank'; link.rel = 'noopener noreferrer'; // Insert the image into the link img.parentNode.insertBefore(link, img); link.appendChild(img); }); } // Wait for review candidate images to appear and link them function waitForReviewCandidateImages() { if (document.querySelector('.ryp__review-candidate__product-image')) { linkAllReviewCandidateImages(); } else { setTimeout(waitForReviewCandidateImages, 500); } } waitForReviewCandidateImages(); // --- Link review image to product page using ASIN from URL (main review image) --- function linkReviewImageToASIN() { // Only run on review pages if (!window.location.href.includes('/review/')) return; // Get ASIN from URL - handle both URL patterns const params = new URLSearchParams(window.location.search); let asin = params.get('asin'); // If no ASIN in query params, try to extract from path (for create-review URLs) if (!asin && window.location.href.includes('/review/create-review')) { // Extract ASIN from URL like /review/create-review?encoding=UTF&asin=B0DTTHH7Y4 const asinMatch = window.location.href.match(/[?&]asin=([A-Z0-9]{10})/); if (asinMatch) { asin = asinMatch[1]; } } if (!asin) return; // Find the image element (first matching Amazon CDN image in review area) const img = document.querySelector('img[src*="m.media-amazon.com/images/I/"]'); if (!img) return; // Check if already wrapped in a link if (img.parentElement && img.parentElement.tagName === 'A' && img.parentElement.href.includes('/dp/')) return; // Create the product link const link = document.createElement('a'); link.href = `https://www.amazon.ca/dp/${asin}`; link.target = '_blank'; link.rel = 'noopener noreferrer'; // Insert the image into the link img.parentNode.insertBefore(link, img); link.appendChild(img); } // Wait for the review image to appear and link it function waitForReviewImageLink() { if (document.querySelector('img[src*="m.media-amazon.com/images/I/"]')) { linkReviewImageToASIN(); } else { setTimeout(waitForReviewImageLink, 500); } } waitForReviewImageLink(); })();