const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); async function saveDebug(data) { try { await new Promise(resolve => chrome.storage.local.set({ geminiDebugLast: { ...data, at: new Date().toISOString() } }, resolve)); } catch {} } function normMarker(v) { return String(v || '') .trim() .replace(/^"+|"+$/g, '') .replace(/[\s_]+/g, '') .toUpperCase(); } function isVisible(el) { if (!el) return false; const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } async function waitForSelectorAny(selectors, timeoutMs) { const start = Date.now(); while (Date.now() - start < timeoutMs) { for (const sel of selectors) { const el = document.querySelector(sel); if (isVisible(el)) return el; } await sleep(250); } return null; } async function waitForInputEl(timeoutMs) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const els = [ ...document.querySelectorAll('textarea'), ...document.querySelectorAll('div[contenteditable="true"]'), ].filter(isVisible); const best = els.find(e => (e.getAttribute('aria-label') || '').toLowerCase().includes('prompt')) || els.find(e => (e.getAttribute('aria-label') || '').toLowerCase().includes('message')) || els[0]; if (best) return best; await sleep(250); } return null; } function setInputText(el, text) { el.focus(); if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const desc = Object.getOwnPropertyDescriptor(proto, 'value'); if (desc && desc.set) desc.set.call(el, text); else el.value = text; try { el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertFromPaste', data: text })); } catch {} try { el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: text })); } catch { el.dispatchEvent(new Event('input', { bubbles: true })); } el.dispatchEvent(new Event('change', { bubbles: true })); return; } const editable = el.getAttribute('contenteditable') === 'true'; if (editable) { el.focus(); const sel = window.getSelection && window.getSelection(); if (sel) { const range = document.createRange(); range.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(range); } try { document.execCommand('insertText', false, text); } catch { el.textContent = text; } } else { el.textContent = text; } try { el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: text })); } catch { el.dispatchEvent(new Event('input', { bubbles: true })); } } function findSendButton() { const selectors = [ 'button[type="submit"]', 'button[aria-label="Send message"]', 'button[aria-label="Send"]', 'button[aria-label*="Send"]', 'button[aria-label*="send"]', 'button[aria-label*="Submit"]', 'button[aria-label*="submit"]', 'button[data-testid*="send"]', 'button.send-button', 'button.submit', ]; for (const sel of selectors) { const btns = [...document.querySelectorAll(sel)]; for (const btn of btns) { if (isVisible(btn)) return btn; } } const btns = [...document.querySelectorAll('button')].filter(isVisible); const labeled = btns.filter(b => { const label = `${b.getAttribute('aria-label') || ''} ${b.getAttribute('title') || ''} ${b.textContent || ''}`.toLowerCase(); return label.includes('send message') || label === 'send' || label.includes(' send ') || label.includes('submit'); }); if (labeled.length) return labeled[labeled.length - 1]; return null; } function triggerEnter(el) { const down = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter' }); const up = new KeyboardEvent('keyup', { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter' }); el.dispatchEvent(down); el.dispatchEvent(up); } function isAriaDisabled(el) { const v = (el?.getAttribute('aria-disabled') || '').toLowerCase(); return v === 'true'; } function clickLikeAUser(el) { if (!el) return; const r = el.getBoundingClientRect ? el.getBoundingClientRect() : null; const clientX = r ? Math.round(r.left + r.width / 2) : 1; const clientY = r ? Math.round(r.top + r.height / 2) : 1; try { el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX, clientY, pointerType: 'mouse', isPrimary: true })); } catch {} try { el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX, clientY })); } catch {} try { el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX, clientY, pointerType: 'mouse', isPrimary: true })); } catch {} try { el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX, clientY })); } catch {} try { el.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX, clientY })); } catch {} try { el.click(); } catch {} } function findSendButtonNear(inputEl) { const containers = []; const form = inputEl?.closest && inputEl.closest('form'); if (form) containers.push(form); let p = inputEl; for (let i = 0; i < 6 && p; i++) { if (p.parentElement) containers.push(p.parentElement); p = p.parentElement; } const selectors = [ 'button[aria-label="Send message"]', 'button.send-button', 'button.submit', 'button[type="submit"]', ]; for (const c of containers) { for (const sel of selectors) { const btns = [...c.querySelectorAll(sel)].filter(isVisible); if (btns.length) return btns[btns.length - 1]; } } return findSendButton(); } async function waitForSendEnabled(inputEl, timeoutMs) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const btn = findSendButtonNear(inputEl); if (btn && isVisible(btn) && !btn.disabled && !isAriaDisabled(btn)) return btn; await sleep(250); } return findSendButtonNear(inputEl); } async function sendMessageNow(inputEl) { for (let i = 0; i < 3; i++) { const sendBtn = await waitForSendEnabled(inputEl, 4000); await saveDebug({ attempt: i + 1, hasSendBtn: !!sendBtn, sendBtnDisabled: !!sendBtn?.disabled, sendBtnAriaDisabled: (sendBtn?.getAttribute && sendBtn.getAttribute('aria-disabled')) || null, sendBtnLabel: (sendBtn?.getAttribute && sendBtn.getAttribute('aria-label')) || null, }); if (sendBtn && !sendBtn.disabled && !isAriaDisabled(sendBtn)) { try { sendBtn.scrollIntoView({ block: 'center', inline: 'center' }); } catch {} clickLikeAUser(sendBtn); await sleep(600); if (isGenerating()) return true; const form = sendBtn.closest && sendBtn.closest('form'); if (form && form.requestSubmit) { try { form.requestSubmit(); } catch {} await sleep(600); if (isGenerating()) return true; } } triggerEnter(inputEl); await sleep(600); if (isGenerating()) return true; try { inputEl.dispatchEvent(new Event('input', { bubbles: true })); } catch {} await sleep(250); } return false; } function isGenerating() { const stopBtn = document.querySelector('button[aria-label*="Stop"], button[aria-label*="stop"]'); if (isVisible(stopBtn)) return true; const progress = document.querySelector('[role="progressbar"]'); if (isVisible(progress)) return true; return false; } function extractJsonObjects(text, maxObjects = 20) { if (!text) return []; const s = String(text); const out = []; let i = 0; while (i < s.length && out.length < maxObjects) { const start = s.indexOf('{', i); if (start === -1) break; let depth = 0; let inStr = false; let esc = false; for (let j = start; j < s.length; j++) { const ch = s[j]; if (inStr) { if (esc) esc = false; else if (ch === '\\\\') esc = true; else if (ch === '"') inStr = false; continue; } if (ch === '"') { inStr = true; continue; } if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) { out.push(s.slice(start, j + 1)); i = j + 1; break; } } if (j === s.length - 1) i = s.length; } if (i === start) i = start + 1; } return out; } function chooseBestJsonFromText(text, marker) { const markerNorm = normMarker(marker); const candidates = extractJsonObjects(text, 25); const expectedKeys = [ 'summary', 'store_description', 'features', 'sound_profile', 'highlight_tracks', 'production', 'market_insight', 'sales_angles', 'embedding', ]; let best = null; let bestScore = -Infinity; for (const cand of candidates) { let parsed; try { parsed = JSON.parse(cand); } catch { continue; } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue; let score = 0; const eomNorm = normMarker(parsed.eom); if (markerNorm && eomNorm === markerNorm) score += 200; for (const k of expectedKeys) if (Object.prototype.hasOwnProperty.call(parsed, k)) score += 10; const placeholderSummary = 'Short factual description (1-2 sentences)'; const placeholderStore = 'Punchy, slightly opinionated record store blurb (max 80 words, human tone, not generic AI)'; if (parsed.summary === placeholderSummary) score -= 50; if (parsed.store_description === placeholderStore) score -= 50; if (parsed.embedding && typeof parsed.embedding === 'object') score += 5; if (parsed.embedding?.description && typeof parsed.embedding.description === 'string') score += 5; if (parsed.embedding?.vibe && typeof parsed.embedding.vibe === 'string') score += 5; if (parsed.embedding?.sales && typeof parsed.embedding.sales === 'string') score += 5; if (cand.length > 2000) score += 5; if (score > bestScore) { bestScore = score; best = parsed; } } return best; } function extractLastResponseText() { const selectors = [ 'main div[class*="markdown"]', 'main div[class*="response"]', 'main div[class*="model"]', 'main [role="article"]', 'main article', ]; const seen = new Set(); const nodes = []; for (const sel of selectors) { for (const el of document.querySelectorAll(sel)) { if (!isVisible(el)) continue; const txt = (el.innerText || '').trim(); if (txt.length < 40) continue; if (seen.has(txt)) continue; seen.add(txt); nodes.push({ el, txt }); } } if (!nodes.length) { const main = document.querySelector('main'); const txt = (main?.innerText || '').trim(); return txt.length ? txt : null; } const scored = nodes.map(n => { const t = n.txt; let s = t.length / 1000; if (t.includes('__PLICE_EOM__')) s += 50; if (t.trimStart().startsWith('{')) s += 10; if (t.includes('You said')) s -= 20; if (t.includes('INPUT JSON:')) s -= 10; if (t.includes('OUTPUT SCHEMA:')) s -= 10; if (t.includes('Gemini is AI and can make mistakes')) s -= 5; return { ...n, score: s }; }).sort((a, b) => b.score - a.score); return scored[0].txt; } async function waitForStableResponse(timeoutMs) { const start = Date.now(); let last = null; let stable = 0; while (Date.now() - start < timeoutMs) { const txt = extractLastResponseText(); const gen = isGenerating(); if (txt && txt === last) stable += 1; else stable = 0; last = txt; if (txt && txt.includes('__PLICE_EOM__') && !gen && stable >= 2) return txt; if (txt && !gen && stable >= 6) return txt; await sleep(500); } return extractLastResponseText(); } function parseJsonMaybe(text) { if (!text) return null; const trimmed = text.trim(); const start = trimmed.indexOf('{'); const end = trimmed.lastIndexOf('}'); if (start === -1 || end === -1 || end <= start) return null; const candidate = trimmed.slice(start, end + 1); try { return JSON.parse(candidate); } catch { return null; } } function findJsonWithMarker(text, marker) { if (!text || !marker) return null; const i = text.indexOf(marker); if (i === -1) return null; const before = text.lastIndexOf('{', i); const after = text.indexOf('}', i); if (before === -1 || after === -1 || after <= before) return null; const candidate = text.slice(before, after + 1); try { return JSON.parse(candidate); } catch { return null; } } async function waitForJsonAndText(marker, timeoutMs) { const start = Date.now(); const want = normMarker(marker); while (Date.now() - start < timeoutMs) { const txt = extractLastResponseText() || ''; if (txt) { const parsed = chooseBestJsonFromText(txt, marker); if (parsed) { const got = normMarker(parsed?.eom); if (!want || got === want) return { text: txt, json: parsed }; } if (marker && txt.includes(marker) && !isGenerating()) return { text: txt, json: parsed || chooseBestJsonFromText(txt, marker) || parseJsonMaybe(txt) }; } await sleep(500); } const finalTxt = extractLastResponseText(); return { text: finalTxt, json: chooseBestJsonFromText(finalTxt, marker) || parseJsonMaybe(finalTxt) }; } async function sendToBackground(message) { return await new Promise(resolve => { try { chrome.runtime.sendMessage(message, (resp) => { const err = chrome.runtime?.lastError?.message; if (err) resolve({ ok: false, error: err }); else resolve(resp || { ok: false, error: 'No response' }); }); } catch (e) { resolve({ ok: false, error: e.message || 'sendMessage failed' }); } }); } async function run() { const job = await new Promise(resolve => chrome.storage.local.get(['geminiJob'], r => resolve(r.geminiJob || null))); if (!job || !job.prompt || (job.status !== 'pending' && job.status !== 'running')) return; // If the job is older than 15 minutes and still pending/running, it's stale — abandon it // so it doesn't re-inject text into Gemini on every page load. const jobAge = job.created_at ? (Date.now() - new Date(job.created_at).getTime()) : Infinity; if (jobAge > 15 * 60 * 1000) { await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: 'Job abandoned (stale — older than 15 minutes)', failed_at: new Date().toISOString() } }, resolve)); return; } if (job.status === 'pending') { await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'running', started_at: new Date().toISOString() } }, resolve)); } const inputEl = await waitForInputEl(45000); if (!inputEl) { await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: 'Gemini input box not found.', failed_at: new Date().toISOString() } }, resolve)); return; } setInputText(inputEl, job.prompt); await sleep(500); const sent = await sendMessageNow(inputEl); if (!sent) { await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: 'Failed to trigger Gemini send.', failed_at: new Date().toISOString() } }, resolve)); return; } const marker = '__PLICE_EOM__'; const { text: responseText, json: parsedFromWait } = await waitForJsonAndText(marker, 300000); if (!responseText) { await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: 'No Gemini response detected.', failed_at: new Date().toISOString() } }, resolve)); return; } let outputJson = parsedFromWait || parseJsonMaybe(responseText); if (!outputJson) outputJson = findJsonWithMarker(responseText, marker); await saveDebug({ stage: 'captured', response_chars: responseText.length, has_json: !!outputJson, has_marker_text: responseText.includes(marker), eom_value: outputJson ? outputJson.eom : null, }); const result = { job_id: job.job_id, model: job.model || 'gemini-web', release_id: job.release_id || null, discogs_url: job.discogs_url || null, gemini_url: location.href, created_at: job.created_at, prompt: job.prompt, input_json: job.input_json || null, output_text: responseText, output_json: outputJson, }; try { await navigator.clipboard.writeText(job.prompt + '\n\n' + responseText); } catch {} await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'posting', posting_at: new Date().toISOString() } }, resolve)); const postResp = await sendToBackground({ action: 'postGeminiResult', result }); await saveDebug({ stage: 'posted', postResp }); if (!postResp || !postResp.ok) { const err = postResp?.error || 'Post failed.'; await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'error', error: err, failed_at: new Date().toISOString() }, geminiLastExchange: result }, resolve)); return; } await new Promise(resolve => chrome.storage.local.set({ geminiJob: { ...job, status: 'done', done_at: new Date().toISOString() }, geminiLastExchange: result }, resolve)); const closeOnSuccess = await new Promise(resolve => chrome.storage.local.get(['geminiCloseTabOnSuccess'], r => resolve(r.geminiCloseTabOnSuccess !== false)) ); if (closeOnSuccess) { await sendToBackground({ action: 'closeSenderTab' }); } } if (document.readyState === 'complete' || document.readyState === 'interactive') { run(); } else { window.addEventListener('DOMContentLoaded', () => run(), { once: true }); }