Bug réel : chrome.runtime.sendMessage depuis un content script ne va QU'au background service worker en MV3. Le side panel qui écoute via chrome.runtime.onMessage ne reçoit jamais rien directement. Résultat : la sélection, le titre de la page et le contexte n'atteignaient jamais le panneau. Fix : - background.js : ajouter un onMessage qui forward SELECTION_CHANGED et PAGE_CONTEXT_CHANGED vers toutes les surfaces (side panel). - sidepanel.js applySelectionFromMessage : normaliser les URLs (ignorer query/fragment/trailing slash), amorcer pageUrl depuis le premier message si tabs.query a échoué, accepter une sélection qui arrive avant refreshPageContext. La v0.3.1 souffrait du même bug — la sélection fonctionnait parfois par chance quand Chrome routait le message avant que le side panel ne soit complètement initialisé. Maintenant c'est garanti.
767 lines
25 KiB
JavaScript
767 lines
25 KiB
JavaScript
/** Mettre à false pour le build Chrome Web Store (URL production en dur). */
|
|
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' }
|
|
|
|
/** Compatibilité Chrome/Firefox : Firefox expose l'API sous `browser.*`,
|
|
* Chrome sous `chrome.*`. On normalise pour utiliser `chrome.*` partout dans le code. */
|
|
if (typeof chrome === 'undefined' && typeof browser !== 'undefined') {
|
|
globalThis.chrome = browser
|
|
}
|
|
|
|
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(t('errPermissionDenied'))
|
|
}
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.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 `<option value="${escapeHtml(n.id)}"${sel}>${escapeHtml(indent + (n.name || t('notebookUnnamed')))}</option>`
|
|
})
|
|
.join('')
|
|
return `<select id="notebookSelect" class="notebook-select" aria-label="${escapeHtml(t('destinationNotebook'))}">
|
|
${notebooks.length ? opts : `<option value="">${escapeHtml(t('noNotebooks'))}</option>`}
|
|
</select>`
|
|
}
|
|
|
|
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 `<div class="selection-panel has-text" id="selectionSlot">
|
|
<div class="selection-head">
|
|
<span class="status live"><span class="pulse-dot sky"></span> ${escapeHtml(t('selectionDetected'))}</span>
|
|
<button type="button" class="clear-btn" id="clearSel">${escapeHtml(t('ignore'))}</button>
|
|
</div>
|
|
<div class="selection-body"${rtlAttrs(selectionText)}>「 ${escapeHtml(selectionText)} 」</div>
|
|
</div>`
|
|
}
|
|
return `<div class="selection-hint" id="selectionSlot">
|
|
<p>${escapeHtml(t('selectionHint'))}</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} ${escapeHtml(t('clipSelection'))}
|
|
</button>`
|
|
: ''
|
|
}
|
|
<button type="button" class="btn ${hasSel ? 'btn-secondary' : 'btn-primary'}" id="clipPageBtn" ${pageRestricted ? 'disabled' : ''}>
|
|
${ICON_CLIP} ${escapeHtml(t('clipPage'))}
|
|
</button>
|
|
<button type="button" class="btn-link link-only" id="clipLinkBtn" ${pageRestricted ? 'disabled' : ''}>
|
|
${ICON_LINK} ${escapeHtml(t('saveLinkOnly'))}
|
|
</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'))
|
|
document.getElementById('openLoginFromHint')?.addEventListener('click', () => {
|
|
chrome.tabs.create({ url: apiBase() })
|
|
})
|
|
|
|
// 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) return
|
|
// Tolérance sur la comparaison d'URL : ignorer trailing slash, query,
|
|
// fragment. Si pageUrl est vide (panel ouvert avant refreshPageContext),
|
|
// on accepte la première sélection qui arrive.
|
|
const normalize = (u) => {
|
|
try {
|
|
const p = new URL(u)
|
|
return `${p.origin}${p.pathname.replace(/\/$/, '')}`
|
|
} catch { return u || '' }
|
|
}
|
|
// Si on n'a pas encore de pageUrl mais qu'un message arrive avec URL,
|
|
// on l'utilise pour amorcer la fiche page. Très utile quand chrome.tabs.query
|
|
// rate (ex. onglet sans permission, side panel non focus, etc.).
|
|
if (msg.url && (!pageUrl || normalize(msg.url) !== normalize(pageUrl))) {
|
|
if (!pageUrl) {
|
|
pageUrl = msg.url
|
|
pageTitle = msg.title || pageTitle || ''
|
|
try {
|
|
const u = new URL(msg.url)
|
|
pageDomain = u.hostname
|
|
pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`
|
|
} catch { /* ignore */ }
|
|
} else {
|
|
// URL vraiment différente — ignorer.
|
|
return
|
|
}
|
|
}
|
|
selectionText = msg.text || ''
|
|
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
|
|
if (msg.lang) pageLang = msg.lang
|
|
if (state === 'idle') {
|
|
updateSelectionUI()
|
|
} else {
|
|
console.debug('[memento] selection updated while busy, deferring UI')
|
|
}
|
|
}
|
|
|
|
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 url = `${apiBase()}/api/clip/notebooks`
|
|
const res = await fetch(url, { credentials: 'include' })
|
|
if (!res.ok) {
|
|
connected = false
|
|
updateConnBadge()
|
|
if (res.status === 401) {
|
|
// Diagnostic : préciser l'URL configurée pour aider l'utilisateur à
|
|
// diagnostiquer un mismatch (localhost vs 127.0.0.1 vs LAN IP).
|
|
throw new Error(`${t('errLoginRequired')}\n${t('errLoginHintUrl', apiBase())}`)
|
|
}
|
|
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>`
|
|
: ''
|
|
|
|
const authHint =
|
|
!connected && errorMessage
|
|
? `<div class="auth-hint">${escapeHtml(errorMessage).replace(/\n/g, '<br/>')}
|
|
<div style="margin-top:8px">
|
|
<button type="button" class="btn btn-secondary btn-sm" id="openLoginFromHint">${escapeHtml(t('openMomento'))}</button>
|
|
</div>
|
|
</div>`
|
|
: ''
|
|
|
|
els.screen.innerHTML = `
|
|
${restrictedBlock}
|
|
${authHint}
|
|
|
|
<div>
|
|
<span class="label">${escapeHtml(t('destinationNotebook'))}</span>
|
|
${notebookSelectHtml()}
|
|
</div>
|
|
|
|
<div class="page-card">
|
|
<span class="sub">${escapeHtml(t('activePage'))}</span>
|
|
<div class="page-row">
|
|
<img src="${escapeHtml(pageFavicon)}" alt="" class="page-favicon" data-fallback="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">${escapeHtml(t('analyzingSource'))}</div>
|
|
<div class="state-sub">${escapeHtml(label || t('statusAnalyzing'))}</div>
|
|
<div class="state-detail">${escapeHtml(t('processingDetail'))}</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">${escapeHtml(t('previewBeforeSave'))}</span>
|
|
<label class="field">
|
|
<span>${escapeHtml(t('noteTitleLabel'))}</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">${escapeHtml(t('excerptLabel'))}</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">${escapeHtml(t('saveToMomento'))}</button>
|
|
<button type="button" class="btn-link" id="cancelConfirmBtn">${escapeHtml(t('back'))}</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">${escapeHtml(t('noteSaved'))}</span>
|
|
<div class="note-title"${rtlAttrs(successTitle)}>${escapeHtml(successTitle)}</div>
|
|
<div class="state-detail">${escapeHtml(t('sentToNotebook', 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">${escapeHtml(t('viewInMomento'))} ↗</button>
|
|
<button type="button" class="btn-link" id="againBtn">${escapeHtml(t('clipAnother'))}</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">${escapeHtml(t('failure'))}</div>
|
|
<div class="state-detail">${escapeHtml(errorMessage || t('genericError'))}</div>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button type="button" class="btn btn-danger" id="retryBtn">${escapeHtml(t('retry'))}</button>
|
|
<button type="button" class="btn-link" id="backIdleBtn">${escapeHtml(t('back'))}</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' ? 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()
|
|
})
|