feat(flashcards): révision SM-2, génération IA et page /revision
Some checks failed
CI / Lint, Test & Build (push) Failing after 32s
CI / Deploy production (on server) (push) Has been skipped

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:
Antigravity
2026-05-24 19:22:20 +00:00
parent 8697ae244f
commit 36336e6b0d
50 changed files with 7687 additions and 98 deletions

View File

@@ -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 laccè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> lectiontecté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()