/** Mettre à false pour le build Chrome Web Store (URL production en dur). */ const ALLOW_INSTANCE_CONFIG = true const DEFAULT_BASE = 'https://memento-note.com' const STORAGE_KEYS = { baseUrl: 'memento_clipper_base_url', notebookId: 'memento_clipper_notebook_id' } 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 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'), } 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, '"') } 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}` } 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 } async function ensureContentScript(tabId) { try { const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' }) if (resp?.ok) return true } catch { /* inject */ } try { await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] }) return true } catch { return false } } async function setPickModeOnTab(enabled) { if (!activeTabId || pageRestricted) return const ok = await ensureContentScript(activeTabId) if (!ok) return try { await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled }) } catch { /* ignore */ } } async function syncPickMode() { await setPickModeOnTab(state === 'idle' && !pageRestricted) } 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) return `
${ 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')) // Gérer l'erreur de chargement du favicon 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 applySelectionFromMessage(msg) { if (!msg || msg.url !== pageUrl) return selectionText = msg.text || '' if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl' if (msg.lang) pageLang = msg.lang if (state === 'idle') updateSelectionUI() } async function refreshPageContext() { const tab = await getActiveTab() activeTabId = tab?.id ?? null pageRestricted = isRestrictedUrl(tab?.url) if (!tab?.id || pageRestricted) { pageUrl = tab?.url || '' pageTitle = tab?.title || t('pageNotAccessible') selectionText = '' 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) 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 || '' } catch { try { const [{ result }] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => ({ html: document.documentElement.outerHTML, text: window.getSelection()?.toString().trim() || '', dir: document.documentElement.getAttribute('dir') || '', lang: (document.documentElement.getAttribute('lang') || '').split('-')[0], }), }) pageHtml = result?.html || '' selectionText = result?.text || '' pageDir = result?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr' pageLang = result?.lang || '' } catch { /* ignore */ } } } 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) } catch (e) { notebooks = [] connected = false updateConnBadge() errorMessage = e.message setSettingsStatus(e.message, true) } } 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 renderIdle() { const restrictedBlock = pageRestricted ? `
${escapeHtml(t('restrictedPage'))}
` : '' const authHint = !connected && errorMessage ? `
${escapeHtml(errorMessage)}
` : '' els.screen.innerHTML = ` ${restrictedBlock} ${authHint}
${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 tags = analyzeResult?.tags || [] const reading = formatReadingTime(analyzeResult?.readingTime) const tagsHtml = tags.map((t) => `${escapeHtml(t)}`).join('') els.screen.innerHTML = `
${escapeHtml(t('previewBeforeSave'))} ${ reading ? `
${escapeHtml(reading)}
` : '' } ${ analyzeResult?.summary ? `

${escapeHtml(analyzeResult.summary)}

` : '' } ${ excerpt && pendingClipType !== 'link' ? `
${escapeHtml(t('excerptLabel'))} ${escapeHtml(excerpt)}
` : '' } ${tagsHtml ? `
${tagsHtml}
` : ''}
` 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' } } 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' render() } catch (e) { errorMessage = e.message || t('errNetwork') state = 'error' 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' render() } catch (e) { errorMessage = e.message || t('errNetwork') state = 'error' render() } } chrome.runtime.onMessage.addListener((msg) => { if (msg?.type === 'SELECTION_CHANGED') applySelectionFromMessage(msg) }) chrome.tabs.onActivated.addListener(async () => { if (state !== 'idle') return 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) { await refreshPageContext() await syncPickMode() render() } }) els.settingsBtn?.addEventListener('click', () => { if (!ALLOW_INSTANCE_CONFIG) return els.settingsPanel.hidden = !els.settingsPanel.hidden }) 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') { await refreshPageContext() await syncPickMode() render() } } else if (document.visibilityState === 'hidden') { // Désactiver le pick mode quand le sidepanel est fermé await setPickModeOnTab(false) } }) document.addEventListener('DOMContentLoaded', async () => { applyDocumentLocale() applyInstanceConfigVisibility() applyShellI18n() await loadSettings() try { await ensureApiPermission() } catch (e) { errorMessage = e.message connected = false updateConnBadge() } await refreshPageContext() await syncPickMode() render() })