/** Mettre à false pour le build Chrome Web Store (URL production en dur). */ const ALLOW_INSTANCE_CONFIG = true /** Mettre à false pour masquer le panneau de diagnostic en production. */ const ALLOW_DIAGNOSTICS = true const DEFAULT_BASE = 'https://memento-note.com' const STORAGE_KEYS = { baseUrl: 'memento_clipper_base_url', notebookId: 'memento_clipper_notebook_id' } const SESSION_KEY = 'memento_clipper_session' let state = 'idle' let notebooks = [] let selectedNotebookId = '' let pageUrl = '' let pageTitle = '' let pageDomain = '' let pageFavicon = '' let pageHtml = '' let pageDir = 'ltr' let pageLang = '' let selectionText = '' let pageRestricted = false let lastNoteId = '' let lastNoteUrl = '' let successTitle = '' let successTags = [] let errorMessage = '' let activeTabId = null let pendingClipType = 'page' let analyzeResult = null let editableTitle = '' let connected = false let contentScriptReady = false const diagLogs = [] let diagOpen = false const els = { screen: document.getElementById('screen'), baseUrl: document.getElementById('baseUrl'), settingsPanel: document.getElementById('settingsPanel'), settingsBtn: document.getElementById('settingsBtn'), connBadge: document.getElementById('connBadge'), connLabel: document.getElementById('connLabel'), settingsStatus: document.getElementById('settingsStatus'), applyInstanceBtn: document.getElementById('applyInstanceBtn'), openLoginBtn: document.getElementById('openLoginBtn'), diagPanel: document.getElementById('diagPanel'), diagToggle: document.getElementById('diagToggle'), diagBody: document.getElementById('diagBody'), copyDiagBtn: document.getElementById('copyDiagBtn'), } const ICON_SELECT = '' const ICON_CLIP = '' const ICON_LINK = '' function apiBase() { if (!ALLOW_INSTANCE_CONFIG) return DEFAULT_BASE return (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '') } function isRestrictedUrl(url) { return !url || /^(chrome|chrome-extension|edge|about|moz-extension|devtools):/i.test(url) } async function ensureApiPermission() { const origin = `${apiBase()}/*` const has = await chrome.permissions.contains({ origins: [origin] }) if (!has) { const granted = await chrome.permissions.request({ origins: [origin] }) if (!granted) throw new Error(t('errPermissionDenied')) } } function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function escapePlainText(s) { return escapeHtml(s).replace(/\n/g, '
') } /** * Mini-renderer Markdown sûr pour le side panel. * - Échappe d'abord le texte (anti-XSS) * - Applique ensuite les transformations HTML par regex sur le texte échappé * - Supporte : titres, gras, italique, liens, code, listes à puces, sauts de ligne * * Volontairement limité — pas de tableaux imbriqués, pas de HTML brut. */ function renderMarkdownSafe(md) { if (!md) return '' let s = escapeHtml(md) // Liens : [texte](https://url) → s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1') // Titres : ### (avant ## avant #) s = s.replace(/^### (.+)$/gm, '
$1
') s = s.replace(/^## (.+)$/gm, '

$1

') s = s.replace(/^# (.+)$/gm, '

$1

') // Gras (**texte**) puis italique (*texte* ou _texte_) s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1') s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2') s = s.replace(/(^|\b)_([^_\n]+)_/g, '$1$2') // Code inline `code` s = s.replace(/`([^`\n]+)`/g, '$1') // Listes à puces : lignes commençant par - ou * s = s.replace(/^(\s*)[-*]\s+(.+)$/gm, '$1
  • $2
  • ') s = s.replace(/(
  • .*<\/li>\n?)+/g, (m) => ``) // Sauts de ligne (double = paragraphe) s = s.replace(/\n{2,}/g, '

    ') s = s.replace(/\n/g, '
    ') // Envelopper dans un paragraphe si pas déjà fait if (!s.startsWith('<')) s = `

    ${s}

    ` else if (!/^<(p|h[3-5]|ul|li)/.test(s)) s = `

    ${s}

    ` return s } const RTL_CHAR = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/ const LTR_CHAR = /[A-Za-z0-9]/ function detectTextDirection(text) { const sample = String(text || '').replace(/\s+/g, '').slice(0, 3000) if (!sample) return 'ltr' let rtl = 0 let ltr = 0 for (const ch of sample) { if (RTL_CHAR.test(ch)) rtl++ else if (LTR_CHAR.test(ch)) ltr++ } if (rtl === 0) return 'ltr' return rtl >= ltr ? 'rtl' : 'ltr' } function resolveUiDirection(text) { if (pageDir === 'rtl') return 'rtl' if (pageLang === 'fa' || pageLang === 'ar' || pageLang === 'he') return 'rtl' if (/\/persian\b|\/fa\b|bbc\.com\/persian/i.test(pageUrl)) return 'rtl' return detectTextDirection(text) } function rtlAttrs(text) { if (resolveUiDirection(text) !== 'rtl') return '' const lang = pageLang && ['fa', 'ar', 'he'].includes(pageLang) ? ` lang="${pageLang}"` : '' return ` class="text-rtl" dir="rtl"${lang}` } // --------------------------------------------------------------------------- // Diagnostic logging // --------------------------------------------------------------------------- function logDiag(label, detail = '') { if (!ALLOW_DIAGNOSTICS) { // En prod on garde uniquement la console native, jamais l'UI. console.debug('[memento-diag]', label, detail) return } const line = `[${new Date().toLocaleTimeString()}] ${label}${detail ? ` — ${detail}` : ''}` diagLogs.push(line) if (diagLogs.length > 80) diagLogs.shift() renderDiag() console.debug('[memento-diag]', label, detail) } function renderDiag() { if (!ALLOW_DIAGNOSTICS || !els.diagBody) return els.diagBody.innerHTML = diagLogs.map((l) => `
    ${escapeHtml(l)}
    `).join('') els.diagBody.scrollTop = els.diagBody.scrollHeight } function hideDiagnosticsIfNeeded() { if (ALLOW_DIAGNOSTICS) return els.diagToggle?.setAttribute('hidden', '') els.diagPanel?.setAttribute('hidden', '') } async function copyDiagnostics() { try { const text = diagLogs.join('\n') await navigator.clipboard.writeText(text) logDiag('diagnostics copied') } catch (e) { logDiag('copy failed', String(e)) } } // --------------------------------------------------------------------------- // Notebooks // --------------------------------------------------------------------------- function sortNotebooksHierarchy(list) { const byParent = new Map() for (const n of list) { const pid = n.parentId || '__root__' if (!byParent.has(pid)) byParent.set(pid, []) byParent.get(pid).push(n) } for (const items of byParent.values()) { items.sort((a, b) => (a.name || '').localeCompare(b.name || '', uiLocaleTag())) } const out = [] const seen = new Set() function walk(parentKey, depth) { for (const n of byParent.get(parentKey) || []) { if (seen.has(n.id)) continue seen.add(n.id) out.push({ ...n, depth }) walk(n.id, depth + 1) } } walk('__root__', 0) for (const n of list) { if (!seen.has(n.id)) out.push({ ...n, depth: 0 }) } return out } function notebookSelectHtml() { const sorted = sortNotebooksHierarchy(notebooks) const opts = sorted .map((n) => { const indent = n.depth > 0 ? '\u00A0\u00A0'.repeat(n.depth) + '↳ ' : '' const sel = n.id === selectedNotebookId ? ' selected' : '' return `` }) .join('') return `` } function formatReadingTime(minutes) { const m = Number(minutes) || 0 if (m <= 0) return '' if (m === 1) return t('readingTimeOne') return t('readingTimeOther', String(m)) } async function getActiveTab() { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) return tab } // --------------------------------------------------------------------------- // Content script synchronization (v0.4.4) // --------------------------------------------------------------------------- function storageArea() { return chrome.storage.session || chrome.storage.local } async function ensureContentScript(tabId) { logDiag('ensureContentScript start', `tab=${tabId}`) try { const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' }) if (resp?.ok) { contentScriptReady = true logDiag('PING ok', `pickMode=${resp.pickMode} sel=${resp.selection?.text?.length || 0}`) if (resp.selection) applySelectionFromMessage(resp.selection, { fromPing: true }) return true } } catch (err) { logDiag('PING failed', String(err?.message || err)) } try { await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] }) logDiag('content script injected') // attendre un court instant que le script s'enregistre await new Promise((r) => setTimeout(r, 120)) const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' }) contentScriptReady = resp?.ok || false logDiag('PING after inject', `ok=${contentScriptReady}`) return contentScriptReady } catch (err) { contentScriptReady = false logDiag('content script inject failed', String(err?.message || err)) return false } } async function setPickModeOnTab(enabled) { if (!activeTabId || pageRestricted) { logDiag('setPickMode skipped', `tab=${activeTabId} restricted=${pageRestricted}`) return false } const ok = await ensureContentScript(activeTabId) if (!ok) return false try { const resp = await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled }) contentScriptReady = resp?.ok || false logDiag('SET_PICK_MODE ok', `enabled=${enabled} pickMode=${resp?.pickMode}`) if (resp?.selection) applySelectionFromMessage(resp.selection, { fromSetPickMode: true }) return contentScriptReady } catch (err) { contentScriptReady = false logDiag('SET_PICK_MODE failed', String(err?.message || err)) return false } } async function syncPickMode() { await setPickModeOnTab(state === 'idle' && !pageRestricted) } async function readSelectionFromStorage() { try { const data = await storageArea().get(SESSION_KEY) const session = data?.[SESSION_KEY] if (!session) return null logDiag('storage read', `url=${session.url?.slice(0, 60)} sel=${session.text?.length || 0}`) return session } catch (err) { logDiag('storage read failed', String(err?.message || err)) return null } } function applySelectionFromMessage(msg, { fromStorage = false, fromPing = false, fromSetPickMode = false } = {}) { if (!msg) return const normalizeUrl = (u) => { if (!u) return '' try { return new URL(u).origin + new URL(u).pathname.replace(/\/+$/, '') } catch { return u } } const msgUrl = msg.url || '' const curUrl = pageUrl || '' if (!curUrl) { if (msgUrl) { pageUrl = msgUrl pageTitle = msg.title || pageTitle || '' try { const u = new URL(msgUrl) pageDomain = u.hostname pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32` } catch { /* ignore */ } } else { return } } else if (normalizeUrl(msgUrl) !== normalizeUrl(curUrl)) { return } selectionText = msg.text || '' if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl' if (msg.lang) pageLang = msg.lang logDiag('selection applied', `len=${selectionText.length} source=${fromStorage ? 'storage' : fromPing ? 'ping' : fromSetPickMode ? 'setPickMode' : 'message'}`) if (state === 'idle') { if (!curUrl) render() else updateSelectionUI() } } async function refreshPageContext() { logDiag('refreshPageContext start') const tab = await getActiveTab() activeTabId = tab?.id ?? null pageRestricted = tab?.url ? isRestrictedUrl(tab.url) : false logDiag('active tab', `id=${activeTabId} url=${tab?.url?.slice(0, 80)} restricted=${pageRestricted}`) if (!tab?.id || pageRestricted) { pageUrl = tab?.url || '' pageTitle = tab?.title || t('pageNotAccessible') selectionText = '' contentScriptReady = false return } pageUrl = tab.url pageTitle = tab.title || '' try { const u = new URL(pageUrl) pageDomain = u.hostname pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32` } catch { pageDomain = pageUrl pageFavicon = 'https://www.google.com/s2/favicons?domain=google.com&sz=32' } const ok = await ensureContentScript(tab.id) if (!ok) { // En dernier recours, lire depuis le storage session/local. const session = await readSelectionFromStorage() if (session) applySelectionFromMessage(session, { fromStorage: true }) return } try { const ctx = await chrome.tabs.sendMessage(tab.id, { type: 'GET_CONTEXT' }) pageHtml = ctx?.html || '' selectionText = ctx?.text || '' pageDir = ctx?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr' pageLang = ctx?.lang || '' logDiag('GET_CONTEXT ok', `html=${pageHtml.length} sel=${selectionText.length}`) } catch (err) { logDiag('GET_CONTEXT failed', String(err?.message || err)) // Fallback storage const session = await readSelectionFromStorage() if (session) applySelectionFromMessage(session, { fromStorage: true }) } } async function loadSettings() { const stored = await chrome.storage.sync.get([STORAGE_KEYS.baseUrl, STORAGE_KEYS.notebookId]) if (els.baseUrl) { els.baseUrl.value = ALLOW_INSTANCE_CONFIG ? stored[STORAGE_KEYS.baseUrl] || DEFAULT_BASE : DEFAULT_BASE } await loadNotebooks(stored[STORAGE_KEYS.notebookId]) } async function loadNotebooks(preferredId) { try { await ensureApiPermission() const res = await fetch(`${apiBase()}/api/clip/notebooks`, { credentials: 'include' }) if (!res.ok) { connected = false updateConnBadge() if (res.status === 401) { throw new Error(t('errLoginRequired')) } throw new Error(t('errLoadNotebooks')) } const data = await res.json() notebooks = data.notebooks || [] selectedNotebookId = (preferredId && notebooks.some((n) => n.id === preferredId) ? preferredId : '') || notebooks[0]?.id || '' connected = true updateConnBadge() errorMessage = '' setSettingsStatus(t('notebooksLoaded'), false) logDiag('notebooks loaded', `${notebooks.length}`) } catch (e) { notebooks = [] connected = false updateConnBadge() errorMessage = e.message setSettingsStatus(e.message, true) logDiag('notebooks failed', e.message) } } async function applyInstance() { const url = (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '') if (els.baseUrl) els.baseUrl.value = url await chrome.storage.sync.set({ [STORAGE_KEYS.baseUrl]: url }) setSettingsStatus(t('connecting'), false) await loadNotebooks(selectedNotebookId) if (connected) { setSettingsStatus(t('connectedToUrl', url), false) } } function updateConnBadge() { if (!els.connBadge) return els.connBadge.hidden = !connected if (els.connLabel) els.connLabel.textContent = connected ? t('connected') : t('disconnected') } function setSettingsStatus(msg, isError) { if (!els.settingsStatus) return els.settingsStatus.hidden = !msg els.settingsStatus.textContent = msg || '' els.settingsStatus.className = `settings-status${isError ? ' is-error' : ' is-ok'}` } function applyInstanceConfigVisibility() { if (ALLOW_INSTANCE_CONFIG) return els.settingsPanel?.setAttribute('hidden', '') els.settingsBtn?.setAttribute('hidden', '') if (els.baseUrl) els.baseUrl.value = DEFAULT_BASE } function selectionBlockHtml() { if (selectionText) { return `
    ${escapeHtml(t('selectionDetected'))}
    「 ${escapeHtml(selectionText)} 」
    ` } return `

    ${escapeHtml(t('selectionHint'))}

    ` } function actionsBlockHtml() { const hasSel = Boolean(selectionText) const contentScriptWarning = !contentScriptReady && !pageRestricted ? `
    ${escapeHtml(t('contentScriptMissing') || 'Content script non détecté — rechargez la page pour clipper.')}
    ` : '' return `${contentScriptWarning}
    ${ hasSel ? `` : '' }
    ` } function bindIdleHandlers() { document.getElementById('notebookSelect')?.addEventListener('change', async (e) => { selectedNotebookId = e.target.value || '' await chrome.storage.sync.set({ [STORAGE_KEYS.notebookId]: selectedNotebookId }) }) document.getElementById('clearSel')?.addEventListener('click', () => void clearSelection()) document.getElementById('clipSelBtn')?.addEventListener('click', () => void runAnalyze('selection')) document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page')) document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link')) document.querySelector('.page-favicon')?.addEventListener('error', function() { const fallback = this.getAttribute('data-fallback') if (fallback && this.src !== fallback) { this.src = fallback } }) } async function clearSelection() { selectionText = '' if (activeTabId) { try { await chrome.scripting.executeScript({ target: { tabId: activeTabId }, func: () => window.getSelection()?.removeAllRanges(), }) } catch { /* ignore */ } } updateSelectionUI() } function updateSelectionUI() { const slot = document.getElementById('selectionSlot') const actions = document.getElementById('actionsSlot') if (!slot || !actions || state !== 'idle') { render() return } slot.outerHTML = selectionBlockHtml() actions.outerHTML = actionsBlockHtml() bindIdleHandlers() } function renderIdle() { const restrictedBlock = pageRestricted ? `
    ${escapeHtml(t('restrictedPage'))}
    ` : '' const authHint = !connected && errorMessage ? `
    ${escapeHtml(errorMessage)}
    ` : '' const csWarning = !contentScriptReady && !pageRestricted ? `
    ${escapeHtml(t('contentScriptMissing') || 'Content script non détecté — rechargez la page pour clipper.')}
    ` : '' els.screen.innerHTML = ` ${restrictedBlock} ${authHint} ${csWarning}
    ${escapeHtml(t('destinationNotebook'))} ${notebookSelectHtml()}
    ${escapeHtml(t('activePage'))}
    ${escapeHtml(pageTitle || '—')}
    ${escapeHtml(pageUrl || '—')}
    ${selectionBlockHtml()} ${actionsBlockHtml()} ` bindIdleHandlers() } function renderLoading(label) { els.screen.innerHTML = `
    ${escapeHtml(t('analyzingSource'))}
    ${escapeHtml(label || t('statusAnalyzing'))}
    ${escapeHtml(t('processingDetail'))}
    ` } function renderConfirm() { const excerpt = analyzeResult?.excerpt || '' const summary = analyzeResult?.summary || '' const tags = analyzeResult?.tags || [] const reading = formatReadingTime(analyzeResult?.readingTime) const tagsHtml = tags.map((t) => `${escapeHtml(t)}`).join('') // Le résumé peut contenir du Markdown produit par l'IA. // L'extrait est du texte brut : on l'affiche tel quel, sans interpréter de Markdown. const summaryHtml = summary ? renderMarkdownSafe(summary) : '' const excerptHtml = (excerpt && pendingClipType !== 'link') ? escapePlainText(excerpt) : '' els.screen.innerHTML = `
    ${escapeHtml(t('previewBeforeSave'))} ${reading ? `
    ${escapeHtml(reading)}
    ` : ''} ${tagsHtml ? `
    ${tagsHtml}
    ` : ''} ${summaryHtml ? `
    ${escapeHtml(t('summaryLabel') || 'Résumé')}
    ${summaryHtml}
    ` : ''} ${excerptHtml ? `
    ${escapeHtml(t('excerptLabel'))}
    ${excerptHtml}
    ` : ''}
    ` document.getElementById('titleInput')?.addEventListener('input', (e) => { editableTitle = e.target.value }) document.getElementById('saveBtn')?.addEventListener('click', () => void runSave()) document.getElementById('cancelConfirmBtn')?.addEventListener('click', async () => { state = 'idle' analyzeResult = null await syncPickMode() render() }) } function renderSuccess() { const nb = notebooks.find((n) => n.id === selectedNotebookId) const tagsHtml = successTags.map((t) => `${escapeHtml(t)}`).join('') const reading = formatReadingTime(analyzeResult?.readingTime) els.screen.innerHTML = `
    ${escapeHtml(t('noteSaved'))}
    ${escapeHtml(successTitle)}
    ${escapeHtml(t('sentToNotebook', nb?.name || ''))}
    ${reading ? `
    ${escapeHtml(reading)}
    ` : ''}
    ${tagsHtml ? `
    ${tagsHtml}
    ` : ''}
    ` document.getElementById('viewBtn')?.addEventListener('click', () => { if (lastNoteUrl) chrome.tabs.create({ url: `${apiBase()}${lastNoteUrl}` }) }) document.getElementById('againBtn')?.addEventListener('click', async () => { state = 'idle' analyzeResult = null await refreshPageContext() await syncPickMode() render() }) } function renderError() { els.screen.innerHTML = `
    !
    ${escapeHtml(t('failure'))}
    ${escapeHtml(errorMessage || t('genericError'))}
    ` document.getElementById('retryBtn')?.addEventListener('click', () => { if (analyzeResult) void runSave() else void runAnalyze(pendingClipType) }) document.getElementById('backIdleBtn')?.addEventListener('click', async () => { state = 'idle' errorMessage = '' analyzeResult = null await refreshPageContext() await syncPickMode() render() }) } function render() { if (state === 'loading' || state === 'saving') { return renderLoading(state === 'saving' ? t('statusSaving') : t('statusAnalyzing')) } if (state === 'confirm') return renderConfirm() if (state === 'success') return renderSuccess() if (state === 'error') return renderError() renderIdle() } async function runAnalyze(type) { pendingClipType = type state = 'loading' await setPickModeOnTab(false) render() try { await ensureApiPermission() await chrome.storage.sync.set({ [STORAGE_KEYS.baseUrl]: apiBase(), [STORAGE_KEYS.notebookId]: selectedNotebookId, }) if (type === 'selection') { if (!selectionText) throw new Error(t('errNoSelection')) await refreshPageContext() } let analyzeBody if (type === 'link') { analyzeBody = { url: pageUrl, title: pageTitle, mode: 'link' } } else if (type === 'selection' && selectionText) { analyzeBody = { url: pageUrl, title: pageTitle, mode: 'selection', selection: selectionText } } else { analyzeBody = { url: pageUrl, html: pageHtml, title: pageTitle, mode: 'article' } } logDiag('analyze request', `${type} url=${pageUrl?.slice(0, 60)}`) const analyzeRes = await fetch(`${apiBase()}/api/clip/analyze`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(analyzeBody), }) const analysis = await analyzeRes.json() if (!analyzeRes.ok) throw new Error(analysis.error || t('errAnalyzeFailed')) analyzeResult = analysis editableTitle = analysis.title || pageTitle || pageDomain state = 'confirm' logDiag('analyze ok', `title=${analysis.title?.slice(0, 40)}`) render() } catch (e) { errorMessage = e.message || t('errNetwork') state = 'error' logDiag('analyze error', errorMessage) render() } } async function runSave() { if (!analyzeResult) return state = 'saving' render() try { const title = (editableTitle || analyzeResult.title || pageTitle || pageDomain).trim() const saveRes = await fetch(`${apiBase()}/api/clip/save`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: pageUrl, title, content: analyzeResult.content, summary: analyzeResult.summary, tags: analyzeResult.tags || [], notebookId: selectedNotebookId || undefined, }), }) const saved = await saveRes.json() if (!saveRes.ok) throw new Error(saved.error || t('errSaveFailed')) successTitle = title successTags = analyzeResult.tags || [] lastNoteId = saved.noteId lastNoteUrl = saved.noteUrl state = 'success' logDiag('save ok', `noteId=${lastNoteId}`) render() } catch (e) { errorMessage = e.message || t('errNetwork') state = 'error' logDiag('save error', errorMessage) render() } } chrome.runtime.onMessage.addListener((msg) => { if (msg?.type === 'SELECTION_CHANGED') { logDiag('SELECTION_CHANGED received', `len=${msg.text?.length || 0}`) applySelectionFromMessage(msg) } }) // Surveiller le storage session/local en cas de message perdu. storageArea().onChanged.addListener((changes) => { if (changes[SESSION_KEY]?.newValue) { logDiag('storage changed event') applySelectionFromMessage(changes[SESSION_KEY].newValue, { fromStorage: true }) } }) chrome.tabs.onActivated.addListener(async () => { if (state !== 'idle') return logDiag('tabs.onActivated') await refreshPageContext() await syncPickMode() render() }) chrome.tabs.onUpdated.addListener(async (tabId, info) => { if (info.status !== 'complete' || state !== 'idle') return const tab = await getActiveTab() if (tab?.id === tabId) { logDiag('tabs.onUpdated complete') await refreshPageContext() await syncPickMode() render() } }) els.settingsBtn?.addEventListener('click', () => { if (!ALLOW_INSTANCE_CONFIG) return els.settingsPanel.hidden = !els.settingsPanel.hidden }) els.diagToggle?.addEventListener('click', () => { diagOpen = !diagOpen if (els.diagPanel) els.diagPanel.hidden = !diagOpen if (els.diagToggle) els.diagToggle.setAttribute('aria-expanded', String(diagOpen)) }) els.copyDiagBtn?.addEventListener('click', () => void copyDiagnostics()) document.querySelectorAll('.preset-btn').forEach((btn) => { btn.addEventListener('click', () => { const url = btn.getAttribute('data-url') if (url && els.baseUrl) els.baseUrl.value = url }) }) els.applyInstanceBtn?.addEventListener('click', () => void applyInstance()) els.openLoginBtn?.addEventListener('click', () => { chrome.tabs.create({ url: apiBase() }) }) document.addEventListener('visibilitychange', async () => { if (document.visibilityState === 'visible') { if (state === 'idle') { logDiag('visibility visible') await refreshPageContext() await syncPickMode() render() } } else if (document.visibilityState === 'hidden') { await setPickModeOnTab(false) } }) document.addEventListener('DOMContentLoaded', async () => { applyDocumentLocale() applyInstanceConfigVisibility() applyShellI18n() hideDiagnosticsIfNeeded() await loadSettings() try { await ensureApiPermission() } catch (e) { errorMessage = e.message connected = false updateConnBadge() } await refreshPageContext() await syncPickMode() render() })