Files
Momento/memento-note/extension/sidepanel.js
Antigravity e881004c77
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped
feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
2026-05-24 18:57:33 +00:00

704 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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 =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>'
const ICON_CLIP =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>'
const ICON_LINK =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'
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('Autorisez laccès à votre instance Momento dans Chrome.')
}
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 || '', 'fr'))
}
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 `<option value="${escapeHtml(n.id)}"${sel}>${escapeHtml(indent + (n.name || 'Sans nom'))}</option>`
})
.join('')
return `<select id="notebookSelect" class="notebook-select" aria-label="Carnet de destination">
${notebooks.length ? opts : '<option value="">Aucun carnet</option>'}
</select>`
}
function formatReadingTime(minutes) {
const m = Number(minutes) || 0
if (m <= 0) return ''
if (m === 1) return '1 min de lecture'
return `${m} min de lecture`
}
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 ? 'Connecté' : 'Déconnecté'
}
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 `<div class="selection-panel has-text" id="selectionSlot">
<div class="selection-head">
<span class="status live"><span class="pulse-dot sky"></span> Sélection détectée</span>
<button type="button" class="clear-btn" id="clearSel">Ignorer</button>
</div>
<div class="selection-body"${rtlAttrs(selectionText)}>「 ${escapeHtml(selectionText)} 」</div>
</div>`
}
return `<div class="selection-hint" id="selectionSlot">
<p>Astuce : surlignez du texte sur la page pour clipper une sélection précise. Le panneau reste ouvert pendant la sélection.</p>
</div>`
}
function actionsBlockHtml() {
const hasSel = Boolean(selectionText)
return `<div class="actions" id="actionsSlot">
${
hasSel
? `<button type="button" class="btn btn-sky" id="clipSelBtn">
${ICON_SELECT} Clipper la sélection
</button>`
: ''
}
<button type="button" class="btn ${hasSel ? 'btn-secondary' : 'btn-primary'}" id="clipPageBtn" ${pageRestricted ? 'disabled' : ''}>
${ICON_CLIP} Clipper cette page
</button>
<button type="button" class="btn-link link-only" id="clipLinkBtn" ${pageRestricted ? 'disabled' : ''}>
${ICON_LINK} Enregistrer le lien seul
</button>
</div>`
}
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'))
}
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 || 'Page non accessible'
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('Connectez-vous à Momento sur la même URL (bouton « Ouvrir Momento »).')
}
throw new Error('Impossible de charger les carnets.')
}
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('Carnets chargés.', 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('Connexion en cours…', false)
await loadNotebooks(selectedNotebookId)
if (connected) {
setSettingsStatus(`Connecté à ${url}`, false)
}
}
function renderIdle() {
const restrictedBlock = pageRestricted
? `<div class="restricted-note">Cette page ne peut pas être clippée (page système Chrome). Ouvrez un site web normal.</div>`
: ''
const authHint =
!connected && errorMessage
? `<div class="auth-hint">${escapeHtml(errorMessage)}</div>`
: ''
els.screen.innerHTML = `
${restrictedBlock}
${authHint}
<div>
<span class="label">Carnet de destination</span>
${notebookSelectHtml()}
</div>
<div class="page-card">
<span class="sub">Page active</span>
<div class="page-row">
<img src="${escapeHtml(pageFavicon)}" alt="" onerror="this.src='https://www.google.com/s2/favicons?domain=google.com&sz=32'" />
<div class="page-text">
<div class="title"${rtlAttrs(pageTitle)}>${escapeHtml(pageTitle || '—')}</div>
<div class="url">${escapeHtml(pageUrl || '—')}</div>
</div>
</div>
</div>
${selectionBlockHtml()}
${actionsBlockHtml()}
`
bindIdleHandlers()
}
function renderLoading(label) {
els.screen.innerHTML = `
<div class="center-state">
<div class="spinner-wrap">
<div class="spinner-ring"></div>
<div class="spinner"></div>
</div>
<div>
<div class="state-title">Analyse de la source</div>
<div class="state-sub">${escapeHtml(label || 'Traitement en cours…')}</div>
<div class="state-detail">Résumé, tags et préparation de la note Momento.</div>
</div>
</div>
`
}
function renderConfirm() {
const excerpt = analyzeResult?.excerpt || ''
const tags = analyzeResult?.tags || []
const reading = formatReadingTime(analyzeResult?.readingTime)
const tagsHtml = tags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
els.screen.innerHTML = `
<div class="confirm-panel">
<span class="label">Aperçu avant enregistrement</span>
<label class="field">
<span>Titre de la note</span>
<input id="titleInput" type="text" value="${escapeHtml(editableTitle)}" maxlength="300" />
</label>
${
reading
? `<div class="meta-row"><span class="reading-time">${escapeHtml(reading)}</span></div>`
: ''
}
${
analyzeResult?.summary
? `<p class="summary-preview"${rtlAttrs(analyzeResult.summary)}>${escapeHtml(analyzeResult.summary)}</p>`
: ''
}
${
excerpt && pendingClipType !== 'link'
? `<div class="excerpt-preview"${rtlAttrs(excerpt)}>
<span class="excerpt-label">Extrait</span>
${escapeHtml(excerpt)}
</div>`
: ''
}
${tagsHtml ? `<div class="tags preview-tags">${tagsHtml}</div>` : ''}
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="saveBtn">Enregistrer dans Momento</button>
<button type="button" class="btn-link" id="cancelConfirmBtn">Retour</button>
</div>
`
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) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
const reading = formatReadingTime(analyzeResult?.readingTime)
els.screen.innerHTML = `
<div class="center-state" style="justify-content:flex-start;padding-top:12px">
<div class="success-icon">✓</div>
<div>
<span class="badge-ok">Note enregistrée</span>
<div class="note-title"${rtlAttrs(successTitle)}>${escapeHtml(successTitle)}</div>
<div class="state-detail">Carnet « ${escapeHtml(nb?.name || '')} »</div>
${reading ? `<div class="state-detail">${escapeHtml(reading)}</div>` : ''}
</div>
${tagsHtml ? `<div class="tags">${tagsHtml}</div>` : ''}
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="viewBtn">Voir dans Momento ↗</button>
<button type="button" class="btn-link" id="againBtn">Clipper autre chose</button>
</div>
`
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 = `
<div class="center-state">
<div class="error-icon">!</div>
<div>
<div class="state-title" style="color:#ef4444">Échec</div>
<div class="state-detail">${escapeHtml(errorMessage || 'Une erreur s\'est produite.')}</div>
</div>
</div>
<div class="actions">
<button type="button" class="btn btn-danger" id="retryBtn">Réessayer</button>
<button type="button" class="btn-link" id="backIdleBtn">Retour</button>
</div>
`
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' ? 'Enregistrement…' : 'Analyse…')
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('Aucune sélection active.')
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 || 'Analyse impossible')
analyzeResult = analysis
editableTitle = analysis.title || pageTitle || pageDomain
state = 'confirm'
render()
} catch (e) {
errorMessage = e.message || 'Erreur réseau'
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 || 'Enregistrement impossible')
successTitle = title
successTags = analyzeResult.tags || []
lastNoteId = saved.noteId
lastNoteUrl = saved.noteUrl
state = 'success'
render()
} catch (e) {
errorMessage = e.message || 'Erreur réseau'
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' && state === 'idle') {
await refreshPageContext()
await syncPickMode()
render()
}
})
document.addEventListener('DOMContentLoaded', async () => {
applyInstanceConfigVisibility()
await loadSettings()
try {
await ensureApiPermission()
} catch (e) {
errorMessage = e.message
connected = false
updateConnBadge()
}
await refreshPageContext()
await syncPickMode()
render()
})