Livre US-FLASHCARDS avec decks, session de révision, stats et migration Prisma. Finalise le Web Clipper (i18n 15 langues) et corrige les erreurs ESLint bloquant la CI. Co-authored-by: Cursor <cursoragent@cursor.com>
708 lines
22 KiB
JavaScript
708 lines
22 KiB
JavaScript
/** 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(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'))
|
|
}
|
|
|
|
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
|
|
? `<div class="restricted-note">${escapeHtml(t('restrictedPage'))}</div>`
|
|
: ''
|
|
|
|
const authHint =
|
|
!connected && errorMessage
|
|
? `<div class="auth-hint">${escapeHtml(errorMessage)}</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="" 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">${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' && state === 'idle') {
|
|
await refreshPageContext()
|
|
await syncPickMode()
|
|
render()
|
|
}
|
|
})
|
|
|
|
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()
|
|
})
|