/** 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' }
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()
})