fix(extension): sync robuste content/sidepanel, diagnostic panel, preview propre v0.4.4
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled

- content.js : écrit la sélection dans chrome.storage.session/local en plus du message runtime
- sidepanel.js : lit le storage en fallback, loggue chaque étape, affiche un diagnostic panel
- sidepanel.js : avertissement explicite si le content script n'est pas détecté
- sidepanel.js : sépare le rendu Markdown du résumé et l'affichage texte brut de l'extrait
- sidepanel.css : agrandit la zone de prévisualisation
- i18n : nouvelles clés contentScriptMissing, diagnosticsTitle, copyDiagnostics
- locales : correction FR (Retour, Enregistrement, Note, etc.) + version 0.4.4 partout
- extract-article.ts : nettoie textContent des métadonnées (dates, temps de lecture, sources)
- analyze/route.ts : excerpt propre via makeExcerpt
- builds Chrome Store et Firefox regénérés en 0.4.4
This commit is contained in:
Antigravity
2026-07-18 16:56:51 +00:00
parent bff060ad20
commit 0ee2afbd62
67 changed files with 1794 additions and 640 deletions

View File

@@ -2,6 +2,7 @@
const ALLOW_INSTANCE_CONFIG = false
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 = []
@@ -25,6 +26,9 @@ 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'),
@@ -36,6 +40,10 @@ const els = {
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 =
@@ -71,6 +79,10 @@ function escapeHtml(s) {
.replace(/"/g, '"')
}
function escapePlainText(s) {
return escapeHtml(s).replace(/\n/g, '<br/>')
}
/**
* Mini-renderer Markdown sûr pour le side panel.
* - Échappe d'abord le texte (anti-XSS)
@@ -136,6 +148,38 @@ function rtlAttrs(text) {
return ` class="text-rtl" dir="rtl"${lang}`
}
// ---------------------------------------------------------------------------
// Diagnostic logging
// ---------------------------------------------------------------------------
function logDiag(label, detail = '') {
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 (!els.diagBody) return
els.diagBody.innerHTML = diagLogs.map((l) => `<div class="diag-line">${escapeHtml(l)}</div>`).join('')
els.diagBody.scrollTop = els.diagBody.scrollHeight
}
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) {
@@ -189,29 +233,60 @@ async function getActiveTab() {
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) return true
} catch {
/* inject */
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'] })
return true
} catch {
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) return
if (!activeTabId || pageRestricted) {
logDiag('setPickMode skipped', `tab=${activeTabId} restricted=${pageRestricted}`)
return false
}
const ok = await ensureContentScript(activeTabId)
if (!ok) return
if (!ok) return false
try {
await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled })
} catch {
/* ignore */
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
}
}
@@ -219,6 +294,158 @@ 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
@@ -256,7 +483,10 @@ function selectionBlockHtml() {
function actionsBlockHtml() {
const hasSel = Boolean(selectionText)
return `<div class="actions" id="actionsSlot">
const contentScriptWarning = !contentScriptReady && !pageRestricted
? `<div class="cs-warning">${escapeHtml(t('contentScriptMissing') || 'Content script non détecté — rechargez la page pour clipper.')}</div>`
: ''
return `${contentScriptWarning}<div class="actions" id="actionsSlot">
${
hasSel
? `<button type="button" class="btn btn-sky" id="clipSelBtn">
@@ -283,7 +513,6 @@ function bindIdleHandlers() {
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) {
@@ -319,160 +548,6 @@ function updateSelectionUI() {
bindIdleHandlers()
}
function applySelectionFromMessage(msg) {
if (!msg) return
// Comparaison d'URL tolérante : ignorer trailing slash, hash, query.
// Compare origin + pathname, pas l'URL complète.
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 || ''
// Si pageUrl n'est pas encore set (race avec refreshPageContext),
// on accepte le premier message qui arrive et on amorce les métadonnées
// de la page depuis le message. Sans ce fallback, le bouton ne s'affiche
// jamais si chrome.tabs.query rate ou revient avant refreshPageContext.
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)) {
// URL vraiment différente (autre onglet) — ignorer.
return
}
selectionText = msg.text || ''
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
if (msg.lang) pageLang = msg.lang
if (state === 'idle') {
// Si la page card n'existait pas encore (pageUrl vide avant),
// il faut un render() complet pour qu'elle apparaisse.
if (!curUrl) {
render()
} else {
updateSelectionUI()
}
} else {
console.debug('[memento] selection updated while busy, deferring UI')
}
}
async function refreshPageContext() {
const tab = await getActiveTab()
activeTabId = tab?.id ?? null
// Ne marquer restricted QUE si on a vraiment une URL à analyser.
// tab?.url undefined doit être traité comme "inconnu", pas restricted.
pageRestricted = tab?.url ? isRestrictedUrl(tab.url) : false
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
? `<div class="restricted-note">${escapeHtml(t('restrictedPage'))}</div>`
@@ -483,9 +558,14 @@ function renderIdle() {
? `<div class="auth-hint">${escapeHtml(errorMessage)}</div>`
: ''
const csWarning = !contentScriptReady && !pageRestricted
? `<div class="cs-warning">${escapeHtml(t('contentScriptMissing') || 'Content script non détecté — rechargez la page pour clipper.')}</div>`
: ''
els.screen.innerHTML = `
${restrictedBlock}
${authHint}
${csWarning}
<div>
<span class="label">${escapeHtml(t('destinationNotebook'))}</span>
@@ -532,10 +612,10 @@ function renderConfirm() {
const reading = formatReadingTime(analyzeResult?.readingTime)
const tagsHtml = tags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
// Le résumé et l'extrait peuvent contenir du Markdown produit par l'IA.
// On rend le Markdown en HTML (limité et sûr) au lieu de l'afficher brut.
// 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') ? renderMarkdownSafe(excerpt) : ''
const excerptHtml = (excerpt && pendingClipType !== 'link') ? escapePlainText(excerpt) : ''
els.screen.innerHTML = `
<div class="confirm-panel">
@@ -552,7 +632,7 @@ function renderConfirm() {
</section>` : ''}
${excerptHtml ? `<section class="preview-block excerpt-block"${rtlAttrs(excerpt)}>
<header class="preview-block-head"><span class="dot dot-excerpt"></span>${escapeHtml(t('excerptLabel'))}</header>
<div class="preview-block-body md md-excerpt">${excerptHtml}</div>
<div class="preview-block-body md md-excerpt excerpt-plain">${excerptHtml}</div>
</section>` : ''}
</div>
<div class="actions">
@@ -670,6 +750,7 @@ async function runAnalyze(type) {
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',
@@ -682,10 +763,12 @@ async function runAnalyze(type) {
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()
}
}
@@ -717,20 +800,34 @@ async function runSave() {
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') applySelectionFromMessage(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()
@@ -740,6 +837,7 @@ 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()
@@ -751,6 +849,14 @@ els.settingsBtn?.addEventListener('click', () => {
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')
@@ -766,12 +872,12 @@ els.openLoginBtn?.addEventListener('click', () => {
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
if (state === 'idle') {
logDiag('visibility visible')
await refreshPageContext()
await syncPickMode()
render()
}
} else if (document.visibilityState === 'hidden') {
// Désactiver le pick mode quand le sidepanel est fermé
await setPickModeOnTab(false)
}
})