feat(flashcards): révision SM-2, génération IA et page /revision
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>
This commit is contained in:
@@ -59,7 +59,7 @@ async function ensureApiPermission() {
|
||||
const has = await chrome.permissions.contains({ origins: [origin] })
|
||||
if (!has) {
|
||||
const granted = await chrome.permissions.request({ origins: [origin] })
|
||||
if (!granted) throw new Error('Autorisez l’accès à votre instance Momento dans Chrome.')
|
||||
if (!granted) throw new Error(t('errPermissionDenied'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ function sortNotebooksHierarchy(list) {
|
||||
byParent.get(pid).push(n)
|
||||
}
|
||||
for (const items of byParent.values()) {
|
||||
items.sort((a, b) => (a.name || '').localeCompare(b.name || '', 'fr'))
|
||||
items.sort((a, b) => (a.name || '').localeCompare(b.name || '', uiLocaleTag()))
|
||||
}
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
@@ -133,19 +133,19 @@ function notebookSelectHtml() {
|
||||
.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>`
|
||||
return `<option value="${escapeHtml(n.id)}"${sel}>${escapeHtml(indent + (n.name || t('notebookUnnamed')))}</option>`
|
||||
})
|
||||
.join('')
|
||||
return `<select id="notebookSelect" class="notebook-select" aria-label="Carnet de destination">
|
||||
${notebooks.length ? opts : '<option value="">Aucun carnet</option>'}
|
||||
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 '1 min de lecture'
|
||||
return `${m} min de lecture`
|
||||
if (m === 1) return t('readingTimeOne')
|
||||
return t('readingTimeOther', String(m))
|
||||
}
|
||||
|
||||
async function getActiveTab() {
|
||||
@@ -186,7 +186,7 @@ async function syncPickMode() {
|
||||
function updateConnBadge() {
|
||||
if (!els.connBadge) return
|
||||
els.connBadge.hidden = !connected
|
||||
if (els.connLabel) els.connLabel.textContent = connected ? 'Connecté' : 'Déconnecté'
|
||||
if (els.connLabel) els.connLabel.textContent = connected ? t('connected') : t('disconnected')
|
||||
}
|
||||
|
||||
function setSettingsStatus(msg, isError) {
|
||||
@@ -207,14 +207,14 @@ 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>
|
||||
<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>Astuce : surlignez du texte sur la page pour clipper une sélection précise. Le panneau reste ouvert pendant la sélection.</p>
|
||||
<p>${escapeHtml(t('selectionHint'))}</p>
|
||||
</div>`
|
||||
}
|
||||
|
||||
@@ -224,15 +224,15 @@ function actionsBlockHtml() {
|
||||
${
|
||||
hasSel
|
||||
? `<button type="button" class="btn btn-sky" id="clipSelBtn">
|
||||
${ICON_SELECT} Clipper la sélection
|
||||
${ICON_SELECT} ${escapeHtml(t('clipSelection'))}
|
||||
</button>`
|
||||
: ''
|
||||
}
|
||||
<button type="button" class="btn ${hasSel ? 'btn-secondary' : 'btn-primary'}" id="clipPageBtn" ${pageRestricted ? 'disabled' : ''}>
|
||||
${ICON_CLIP} Clipper cette page
|
||||
${ICON_CLIP} ${escapeHtml(t('clipPage'))}
|
||||
</button>
|
||||
<button type="button" class="btn-link link-only" id="clipLinkBtn" ${pageRestricted ? 'disabled' : ''}>
|
||||
${ICON_LINK} Enregistrer le lien seul
|
||||
${ICON_LINK} ${escapeHtml(t('saveLinkOnly'))}
|
||||
</button>
|
||||
</div>`
|
||||
}
|
||||
@@ -290,7 +290,7 @@ async function refreshPageContext() {
|
||||
|
||||
if (!tab?.id || pageRestricted) {
|
||||
pageUrl = tab?.url || ''
|
||||
pageTitle = tab?.title || 'Page non accessible'
|
||||
pageTitle = tab?.title || t('pageNotAccessible')
|
||||
selectionText = ''
|
||||
return
|
||||
}
|
||||
@@ -354,9 +354,9 @@ async function loadNotebooks(preferredId) {
|
||||
connected = false
|
||||
updateConnBadge()
|
||||
if (res.status === 401) {
|
||||
throw new Error('Connectez-vous à Momento sur la même URL (bouton « Ouvrir Momento »).')
|
||||
throw new Error(t('errLoginRequired'))
|
||||
}
|
||||
throw new Error('Impossible de charger les carnets.')
|
||||
throw new Error(t('errLoadNotebooks'))
|
||||
}
|
||||
const data = await res.json()
|
||||
notebooks = data.notebooks || []
|
||||
@@ -367,7 +367,7 @@ async function loadNotebooks(preferredId) {
|
||||
connected = true
|
||||
updateConnBadge()
|
||||
errorMessage = ''
|
||||
setSettingsStatus('Carnets chargés.', false)
|
||||
setSettingsStatus(t('notebooksLoaded'), false)
|
||||
} catch (e) {
|
||||
notebooks = []
|
||||
connected = false
|
||||
@@ -381,16 +381,16 @@ 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)
|
||||
setSettingsStatus(t('connecting'), false)
|
||||
await loadNotebooks(selectedNotebookId)
|
||||
if (connected) {
|
||||
setSettingsStatus(`Connecté à ${url}`, false)
|
||||
setSettingsStatus(t('connectedToUrl', 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>`
|
||||
? `<div class="restricted-note">${escapeHtml(t('restrictedPage'))}</div>`
|
||||
: ''
|
||||
|
||||
const authHint =
|
||||
@@ -403,12 +403,12 @@ function renderIdle() {
|
||||
${authHint}
|
||||
|
||||
<div>
|
||||
<span class="label">Carnet de destination</span>
|
||||
<span class="label">${escapeHtml(t('destinationNotebook'))}</span>
|
||||
${notebookSelectHtml()}
|
||||
</div>
|
||||
|
||||
<div class="page-card">
|
||||
<span class="sub">Page active</span>
|
||||
<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">
|
||||
@@ -432,9 +432,9 @@ function renderLoading(label) {
|
||||
<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 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>
|
||||
`
|
||||
@@ -448,9 +448,9 @@ function renderConfirm() {
|
||||
|
||||
els.screen.innerHTML = `
|
||||
<div class="confirm-panel">
|
||||
<span class="label">Aperçu avant enregistrement</span>
|
||||
<span class="label">${escapeHtml(t('previewBeforeSave'))}</span>
|
||||
<label class="field">
|
||||
<span>Titre de la note</span>
|
||||
<span>${escapeHtml(t('noteTitleLabel'))}</span>
|
||||
<input id="titleInput" type="text" value="${escapeHtml(editableTitle)}" maxlength="300" />
|
||||
</label>
|
||||
${
|
||||
@@ -466,7 +466,7 @@ function renderConfirm() {
|
||||
${
|
||||
excerpt && pendingClipType !== 'link'
|
||||
? `<div class="excerpt-preview"${rtlAttrs(excerpt)}>
|
||||
<span class="excerpt-label">Extrait</span>
|
||||
<span class="excerpt-label">${escapeHtml(t('excerptLabel'))}</span>
|
||||
${escapeHtml(excerpt)}
|
||||
</div>`
|
||||
: ''
|
||||
@@ -474,8 +474,8 @@ function renderConfirm() {
|
||||
${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>
|
||||
<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>
|
||||
`
|
||||
|
||||
@@ -500,16 +500,16 @@ function renderSuccess() {
|
||||
<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>
|
||||
<span class="badge-ok">${escapeHtml(t('noteSaved'))}</span>
|
||||
<div class="note-title"${rtlAttrs(successTitle)}>${escapeHtml(successTitle)}</div>
|
||||
<div class="state-detail">Carnet « ${escapeHtml(nb?.name || '')} »</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">Voir dans Momento ↗</button>
|
||||
<button type="button" class="btn-link" id="againBtn">Clipper autre chose</button>
|
||||
<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', () => {
|
||||
@@ -529,13 +529,13 @@ function renderError() {
|
||||
<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 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">Réessayer</button>
|
||||
<button type="button" class="btn-link" id="backIdleBtn">Retour</button>
|
||||
<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', () => {
|
||||
@@ -553,7 +553,9 @@ function renderError() {
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (state === 'loading' || state === 'saving') return renderLoading(state === 'saving' ? 'Enregistrement…' : 'Analyse…')
|
||||
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()
|
||||
@@ -573,7 +575,7 @@ async function runAnalyze(type) {
|
||||
})
|
||||
|
||||
if (type === 'selection') {
|
||||
if (!selectionText) throw new Error('Aucune sélection active.')
|
||||
if (!selectionText) throw new Error(t('errNoSelection'))
|
||||
await refreshPageContext()
|
||||
}
|
||||
|
||||
@@ -593,14 +595,14 @@ async function runAnalyze(type) {
|
||||
body: JSON.stringify(analyzeBody),
|
||||
})
|
||||
const analysis = await analyzeRes.json()
|
||||
if (!analyzeRes.ok) throw new Error(analysis.error || 'Analyse impossible')
|
||||
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 || 'Erreur réseau'
|
||||
errorMessage = e.message || t('errNetwork')
|
||||
state = 'error'
|
||||
render()
|
||||
}
|
||||
@@ -626,7 +628,7 @@ async function runSave() {
|
||||
}),
|
||||
})
|
||||
const saved = await saveRes.json()
|
||||
if (!saveRes.ok) throw new Error(saved.error || 'Enregistrement impossible')
|
||||
if (!saveRes.ok) throw new Error(saved.error || t('errSaveFailed'))
|
||||
|
||||
successTitle = title
|
||||
successTags = analyzeResult.tags || []
|
||||
@@ -635,7 +637,7 @@ async function runSave() {
|
||||
state = 'success'
|
||||
render()
|
||||
} catch (e) {
|
||||
errorMessage = e.message || 'Erreur réseau'
|
||||
errorMessage = e.message || t('errNetwork')
|
||||
state = 'error'
|
||||
render()
|
||||
}
|
||||
@@ -688,7 +690,9 @@ document.addEventListener('visibilitychange', async () => {
|
||||
})
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
applyDocumentLocale()
|
||||
applyInstanceConfigVisibility()
|
||||
applyShellI18n()
|
||||
await loadSettings()
|
||||
try {
|
||||
await ensureApiPermission()
|
||||
|
||||
Reference in New Issue
Block a user