feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
This commit is contained in:
46
memento-note/extension/README.md
Normal file
46
memento-note/extension/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Memento Web Clipper — extension Chrome
|
||||
|
||||
Clipper web avec **panneau latéral** : le panneau reste ouvert pendant que vous surlignez du texte sur la page.
|
||||
|
||||
## Installation (dev)
|
||||
|
||||
1. Chrome → `chrome://extensions`
|
||||
2. **Mode développeur** → **Charger l’extension non empaquetée** → dossier `memento-note/extension`
|
||||
3. Épingle l’icône Momento
|
||||
|
||||
> Chrome **114+** requis (Side Panel API).
|
||||
|
||||
## Instance Momento
|
||||
|
||||
- **Dev** : icône ⚙ → URL (`http://localhost:3000` ou IP LAN) → **Appliquer & reconnecter**
|
||||
- Connectez-vous sur **la même URL** dans Chrome (Google OAuth)
|
||||
- **Production (build Store)** : mettre `ALLOW_INSTANCE_CONFIG = false` dans `sidepanel.js` → URL `https://memento-note.com` en dur, réglages masqués
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. Ouvrez une page web normale (pas `chrome://`)
|
||||
2. Cliquez l’icône Momento → panneau latéral
|
||||
3. Choisissez le **carnet** (liste hiérarchique)
|
||||
4. Optionnel : surlignez du texte → **Clipper la sélection** (bouton sky)
|
||||
5. Ou **Clipper cette page** (article complet + IA)
|
||||
6. Ou **Enregistrer le lien seul**
|
||||
7. **Aperçu** : titre éditable, résumé, extrait, temps de lecture → **Enregistrer dans Momento**
|
||||
|
||||
## Dépannage
|
||||
|
||||
| Problème | Solution |
|
||||
|----------|----------|
|
||||
| Carnets vides / 401 | **Ouvrir Momento ↗** sur la même URL, connectez-vous |
|
||||
| `localhost` vs `127.0.0.1` | Utilisez **toujours la même** URL partout (cookies session) |
|
||||
| Pas de sélection | Rechargez la page après install extension ; surlignez sur la page, pas dans le panneau |
|
||||
| Page Chrome système | Impossible — ouvrez un site http(s) normal |
|
||||
|
||||
## Persan / RTL
|
||||
|
||||
Détection automatique `dir` / `lang` (ex. BBC Persian), aperçu RTL avec Vazirmatn.
|
||||
|
||||
## APIs
|
||||
|
||||
- `GET /api/clip/notebooks`
|
||||
- `POST /api/clip/analyze`
|
||||
- `POST /api/clip/save`
|
||||
8
memento-note/extension/background.js
Normal file
8
memento-note/extension/background.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Service worker — ouvre le panneau latéral au clic sur l’icône. */
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||
})
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||
})
|
||||
207
memento-note/extension/content.js
Normal file
207
memento-note/extension/content.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Content script Momento — sélection live, surlignage, communication avec le side panel.
|
||||
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si l’onglet était déjà ouvert.
|
||||
*/
|
||||
;(function initMementoClipperContent() {
|
||||
if (globalThis.__mementoClipperContent) return
|
||||
globalThis.__mementoClipperContent = true
|
||||
|
||||
const HIGHLIGHT_ID = 'memento-clipper-highlight-root'
|
||||
const BANNER_ID = 'memento-clipper-banner-root'
|
||||
const STYLE_ID = 'memento-clipper-styles'
|
||||
|
||||
let pickMode = false
|
||||
let debounceTimer = null
|
||||
|
||||
function getSelectionText() {
|
||||
return window.getSelection()?.toString().trim() || ''
|
||||
}
|
||||
|
||||
function getPageMeta() {
|
||||
const dir =
|
||||
document.documentElement.getAttribute('dir') ||
|
||||
document.body?.getAttribute('dir') ||
|
||||
''
|
||||
const lang = (
|
||||
document.documentElement.getAttribute('lang') ||
|
||||
document.body?.getAttribute('lang') ||
|
||||
''
|
||||
).split('-')[0]
|
||||
return {
|
||||
text: getSelectionText(),
|
||||
dir,
|
||||
lang,
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastSelection() {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() }
|
||||
try {
|
||||
chrome.runtime.sendMessage(payload).catch(() => {})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (pickMode) paintHighlight()
|
||||
}, 80)
|
||||
}
|
||||
|
||||
function removeHighlight() {
|
||||
document.getElementById(HIGHLIGHT_ID)?.remove()
|
||||
}
|
||||
|
||||
function paintHighlight() {
|
||||
removeHighlight()
|
||||
const sel = window.getSelection()
|
||||
if (!sel || sel.isCollapsed || !sel.rangeCount) return
|
||||
|
||||
let range
|
||||
try {
|
||||
range = sel.getRangeAt(0)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.id = HIGHLIGHT_ID
|
||||
host.setAttribute('aria-hidden', 'true')
|
||||
host.style.cssText =
|
||||
'position:fixed;inset:0;pointer-events:none;z-index:2147483644;overflow:hidden;'
|
||||
|
||||
for (const rect of range.getClientRects()) {
|
||||
if (rect.width < 2 || rect.height < 2) continue
|
||||
const box = document.createElement('div')
|
||||
box.style.cssText = [
|
||||
'position:fixed',
|
||||
`left:${rect.left - 2}px`,
|
||||
`top:${rect.top - 1}px`,
|
||||
`width:${rect.width + 4}px`,
|
||||
`height:${rect.height + 2}px`,
|
||||
'background:rgba(164,113,72,0.28)',
|
||||
'border-radius:3px',
|
||||
'box-shadow:0 0 0 1px rgba(164,113,72,0.35)',
|
||||
'transition:opacity 0.15s ease',
|
||||
].join(';')
|
||||
host.appendChild(box)
|
||||
}
|
||||
|
||||
if (host.childNodes.length) document.documentElement.appendChild(host)
|
||||
}
|
||||
|
||||
function ensureStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return
|
||||
const style = document.createElement('style')
|
||||
style.id = STYLE_ID
|
||||
style.textContent = `
|
||||
html.memento-clipper-pick ::selection {
|
||||
background: rgba(164, 113, 72, 0.45) !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
html.memento-clipper-pick {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
`
|
||||
document.documentElement.appendChild(style)
|
||||
}
|
||||
|
||||
function removeBanner() {
|
||||
document.getElementById(BANNER_ID)?.remove()
|
||||
}
|
||||
|
||||
function ensureBanner() {
|
||||
if (document.getElementById(BANNER_ID)) return
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.id = BANNER_ID
|
||||
host.style.cssText =
|
||||
'all:initial;position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:none;font-family:Inter,system-ui,sans-serif;'
|
||||
|
||||
const shadow = host.attachShadow({ mode: 'open' })
|
||||
shadow.innerHTML = `
|
||||
<style>
|
||||
.pill {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 18px; border-radius: 999px;
|
||||
background: #1c1c1c; color: #faf9f5;
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.22);
|
||||
font-size: 12px; font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
animation: slideIn 0.35s cubic-bezier(0.22,1,0.36,1);
|
||||
}
|
||||
.logo {
|
||||
width: 22px; height: 22px; border-radius: 7px;
|
||||
background: #faf9f5; color: #1c1c1c;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: Georgia, serif; font-weight: 900; font-size: 12px;
|
||||
}
|
||||
.dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #a47148; animation: pulse 1.2s ease infinite;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.85); }
|
||||
}
|
||||
</style>
|
||||
<div class="pill">
|
||||
<span class="logo">M</span>
|
||||
<span class="dot"></span>
|
||||
<span>Surlignez le texte à clipper</span>
|
||||
</div>
|
||||
`
|
||||
document.documentElement.appendChild(host)
|
||||
}
|
||||
|
||||
function setPickMode(enabled) {
|
||||
pickMode = !!enabled
|
||||
ensureStyles()
|
||||
if (pickMode) {
|
||||
document.documentElement.classList.add('memento-clipper-pick')
|
||||
ensureBanner()
|
||||
paintHighlight()
|
||||
} else {
|
||||
document.documentElement.classList.remove('memento-clipper-pick')
|
||||
removeBanner()
|
||||
removeHighlight()
|
||||
}
|
||||
}
|
||||
|
||||
function onScrollOrResize() {
|
||||
if (pickMode) paintHighlight()
|
||||
}
|
||||
|
||||
document.addEventListener('selectionchange', broadcastSelection)
|
||||
document.addEventListener('mouseup', broadcastSelection)
|
||||
document.addEventListener('keyup', broadcastSelection)
|
||||
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true })
|
||||
window.addEventListener('resize', onScrollOrResize, { passive: true })
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message?.type === 'PING') {
|
||||
sendResponse({ ok: true })
|
||||
return true
|
||||
}
|
||||
if (message?.type === 'GET_CONTEXT') {
|
||||
sendResponse({
|
||||
html: document.documentElement.outerHTML,
|
||||
...getPageMeta(),
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (message?.type === 'SET_PICK_MODE') {
|
||||
setPickMode(!!message.enabled)
|
||||
sendResponse({ ok: true, pickMode })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
broadcastSelection()
|
||||
})()
|
||||
31
memento-note/extension/manifest.json
Normal file
31
memento-note/extension/manifest.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Memento Web Clipper",
|
||||
"version": "0.3.0",
|
||||
"description": "Enregistrez des pages et des sélections dans Momento avec résumé IA.",
|
||||
"permissions": ["activeTab", "scripting", "storage", "sidePanel", "tabs"],
|
||||
"host_permissions": [
|
||||
"http://localhost:3000/*",
|
||||
"http://127.0.0.1:3000/*",
|
||||
"https://memento-note.com/*",
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle",
|
||||
"all_frames": false
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"default_title": "Momento Web Clipper"
|
||||
}
|
||||
}
|
||||
31
memento-note/extension/scripts/build-extension-locales.mjs
Normal file
31
memento-note/extension/scripts/build-extension-locales.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Genere extension/_locales/<lang>/messages.json depuis i18n/translations.json
|
||||
* Usage: node scripts/build-extension-locales.mjs
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const extRoot = path.resolve(__dirname, '..')
|
||||
const srcPath = path.join(extRoot, 'i18n', 'translations.json')
|
||||
const outRoot = path.join(extRoot, '_locales')
|
||||
|
||||
const { version, strings } = JSON.parse(fs.readFileSync(srcPath, 'utf8'))
|
||||
const langs = Object.keys(strings)
|
||||
|
||||
for (const lang of langs) {
|
||||
const dir = path.join(outRoot, lang)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
const messages = {}
|
||||
for (const [key, def] of Object.entries(strings[lang])) {
|
||||
const entry = { message: def.message.replace(/\{version\}/g, version) }
|
||||
if (def.description) entry.description = def.description
|
||||
if (def.placeholders) entry.placeholders = def.placeholders
|
||||
messages[key] = entry
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, 'messages.json'), JSON.stringify(messages, null, 2) + '\n')
|
||||
}
|
||||
|
||||
console.log(`Generated ${langs.length} locales in ${outRoot}`)
|
||||
490
memento-note/extension/sidepanel.css
Normal file
490
memento-note/extension/sidepanel.css
Normal file
@@ -0,0 +1,490 @@
|
||||
:root {
|
||||
--ink: #1c1c1c;
|
||||
--paper: #faf9f5;
|
||||
--card: #ffffff;
|
||||
--muted: #6b7280;
|
||||
--border: #e8e4dc;
|
||||
--accent: #a47148;
|
||||
--accent-soft: rgba(164, 113, 72, 0.12);
|
||||
--accent-glow: rgba(164, 113, 72, 0.35);
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--shadow: 0 18px 40px rgba(28, 28, 28, 0.08);
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
background: linear-gradient(180deg, #fff 0%, #fcfcfa 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 10px; }
|
||||
.brand-logo {
|
||||
width: 34px; height: 34px; border-radius: 11px;
|
||||
background: var(--ink); color: #faf9f5;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-weight: 900; font-size: 16px;
|
||||
box-shadow: 0 4px 14px rgba(28, 28, 28, 0.18);
|
||||
}
|
||||
.brand-text { line-height: 1.1; }
|
||||
.brand-name {
|
||||
display: block; font-size: 14px; font-weight: 700;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
}
|
||||
.brand-sub {
|
||||
display: block; font-size: 9px; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: var(--accent); font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
border: 1px solid var(--border); background: #fff;
|
||||
color: var(--muted); cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.conn-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.conn-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding: 14px 18px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.settings-panel[hidden] { display: none !important; }
|
||||
.settings-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.settings-hint code {
|
||||
font-size: 10px;
|
||||
background: var(--paper);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.settings-status {
|
||||
margin: 10px 0 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.settings-status.is-ok { color: #059669; }
|
||||
.settings-status.is-error { color: #dc2626; }
|
||||
|
||||
.preset-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.preset-btn {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.preset-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field span {
|
||||
font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em;
|
||||
color: var(--muted); font-weight: 700;
|
||||
}
|
||||
input[type="url"],
|
||||
input[type="text"],
|
||||
.notebook-select {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); background: var(--paper);
|
||||
font-family: inherit; font-size: 12px;
|
||||
}
|
||||
input[type="url"]:focus,
|
||||
input[type="text"]:focus,
|
||||
.notebook-select:focus {
|
||||
outline: none; border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.notebook-select {
|
||||
background: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 16px 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main > .actions {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 10px 18px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
.footer-meta { font-size: 9px; color: #9ca3af; letter-spacing: 0.06em; }
|
||||
|
||||
.label {
|
||||
font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
color: var(--muted); font-weight: 700; margin-bottom: 8px; display: block;
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius);
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
font-size: 11px;
|
||||
color: #92400e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
padding: 14px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,0.8) inset;
|
||||
}
|
||||
.page-card .sub {
|
||||
font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
color: var(--muted); font-weight: 700; display: block; margin-bottom: 8px;
|
||||
}
|
||||
.page-row { display: flex; gap: 10px; align-items: flex-start; min-width: 0; }
|
||||
.page-row img {
|
||||
width: 20px; height: 20px; border-radius: 5px;
|
||||
flex-shrink: 0; margin-top: 2px;
|
||||
}
|
||||
.page-text { min-width: 0; flex: 1; }
|
||||
.page-row .title {
|
||||
font-size: 12px; font-weight: 700; line-height: 1.45;
|
||||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
.page-row .url {
|
||||
font-size: 10px; color: var(--muted); margin-top: 4px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
direction: ltr; text-align: left;
|
||||
}
|
||||
|
||||
.text-rtl {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
font-family: 'Vazirmatn', 'Inter', sans-serif;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.selection-panel {
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selection-panel.has-text {
|
||||
border-color: #bae6fd;
|
||||
background: rgba(14, 165, 233, 0.05);
|
||||
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.12);
|
||||
}
|
||||
.selection-hint {
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
}
|
||||
.selection-hint p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.selection-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(180deg, #fff 0%, #fdfcfa 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.selection-head .status {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 10px; font-weight: 800; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; color: var(--muted);
|
||||
}
|
||||
.selection-head .status.live { color: #0284c7; }
|
||||
.selection-head .count {
|
||||
font-size: 10px; font-weight: 700; color: var(--muted);
|
||||
background: var(--paper); padding: 4px 8px; border-radius: 999px;
|
||||
}
|
||||
.selection-head .count.active { color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
.selection-body {
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
color: rgba(28, 28, 28, 0.88);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
unicode-bidi: plaintext;
|
||||
border-inline-start: 3px solid transparent;
|
||||
}
|
||||
.selection-panel.has-text .selection-body {
|
||||
border-inline-start-color: #38bdf8;
|
||||
padding-inline-start: 16px;
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
|
||||
animation: pulse 1.4s ease infinite;
|
||||
}
|
||||
.pulse-dot.sky { background: #0ea5e9; }
|
||||
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(.85)} }
|
||||
|
||||
.clear-btn {
|
||||
border: none; background: none; font-size: 10px;
|
||||
color: var(--muted); cursor: pointer; font-weight: 600;
|
||||
padding: 4px 6px; border-radius: 6px;
|
||||
}
|
||||
.clear-btn:hover { color: var(--ink); background: var(--paper); }
|
||||
|
||||
.actions {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
margin-top: auto; padding-top: 6px;
|
||||
}
|
||||
.btn {
|
||||
padding: 14px 16px; border-radius: var(--radius); border: none; cursor: pointer;
|
||||
font-weight: 700; font-size: 10px; text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
transition: transform 0.12s ease, opacity 0.12s ease, filter 0.12s ease;
|
||||
}
|
||||
.btn:active { transform: scale(0.98); }
|
||||
.btn:disabled {
|
||||
opacity: 0.42; cursor: not-allowed; transform: none;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--ink); color: #fff;
|
||||
box-shadow: 0 10px 24px rgba(28, 28, 28, 0.18);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { opacity: 0.94; }
|
||||
.btn-sky {
|
||||
background: #0284c7;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 22px rgba(2, 132, 199, 0.22);
|
||||
}
|
||||
.btn-sky:hover:not(:disabled) { background: #0369a1; }
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: #e5e7eb; }
|
||||
.btn-sm {
|
||||
padding: 10px 12px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-link.link-only {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-link {
|
||||
background: none; border: none; color: var(--muted); font-size: 11px;
|
||||
cursor: pointer; padding: 8px; font-weight: 500;
|
||||
}
|
||||
.btn-link:hover { color: var(--ink); text-decoration: underline; }
|
||||
.btn-icon { width: 14px; height: 14px; display: inline-flex; }
|
||||
|
||||
.center-state {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; text-align: center; gap: 14px; padding: 32px 12px;
|
||||
min-height: 280px;
|
||||
}
|
||||
.spinner-wrap { position: relative; width: 52px; height: 52px; }
|
||||
.spinner-ring {
|
||||
position: absolute; inset: 0; border-radius: 50%;
|
||||
border: 1px solid var(--border); animation: ping 1.2s ease infinite;
|
||||
}
|
||||
@keyframes ping { 0%{transform:scale(1);opacity:.6} 100%{transform:scale(1.35);opacity:0} }
|
||||
.spinner {
|
||||
position: absolute; inset: 6px;
|
||||
border: 3px solid var(--border); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: spin 0.75s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.state-title {
|
||||
font-size: 10px; font-weight: 800; text-transform: uppercase;
|
||||
letter-spacing: 0.14em; color: var(--muted);
|
||||
}
|
||||
.state-sub { font-size: 15px; font-weight: 700; color: var(--ink); }
|
||||
.state-detail {
|
||||
font-size: 11px; color: var(--muted); max-width: 280px;
|
||||
line-height: 1.55; margin: 0 auto;
|
||||
}
|
||||
|
||||
.success-icon, .error-icon {
|
||||
width: 58px; height: 58px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 26px; font-weight: 700;
|
||||
}
|
||||
.success-icon {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.25); color: var(--success);
|
||||
}
|
||||
.error-icon { background: #fef2f2; color: var(--danger); }
|
||||
|
||||
.badge-ok {
|
||||
display: inline-block; margin-bottom: 8px;
|
||||
font-size: 9px; background: rgba(16, 185, 129, 0.12);
|
||||
color: #059669; font-weight: 800; padding: 3px 8px; border-radius: 6px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em;
|
||||
}
|
||||
.note-title {
|
||||
font-size: 15px; font-weight: 700;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
line-height: 1.35; margin-top: 6px;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
.tags {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;
|
||||
padding-top: 14px; border-top: 1px solid var(--border); margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.tag-chip {
|
||||
font-size: 9px; font-weight: 800; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--accent);
|
||||
background: var(--accent-soft); border: 1px solid rgba(164, 113, 72, 0.2);
|
||||
padding: 5px 10px; border-radius: 999px;
|
||||
}
|
||||
|
||||
.restricted-note {
|
||||
padding: 14px; border-radius: var(--radius);
|
||||
background: #fef2f2; border: 1px solid #fecaca;
|
||||
font-size: 11px; color: #991b1b; line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirm-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.summary-preview {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
font-style: italic;
|
||||
}
|
||||
.excerpt-preview {
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
.excerpt-label {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.meta-row { margin-top: -4px; }
|
||||
.reading-time {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.preview-tags { justify-content: flex-start; border-top: none; margin-top: 0; padding-top: 0; }
|
||||
62
memento-note/extension/sidepanel.html
Normal file
62
memento-note/extension/sidepanel.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Momento Web Clipper</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Vazirmatn:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="sidepanel.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="shell">
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<div class="brand-logo">M</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-name">Momento</span>
|
||||
<span class="brand-sub">Web Clipper</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div id="connBadge" class="conn-badge" hidden>
|
||||
<span class="conn-dot"></span>
|
||||
<span id="connLabel">Connecté</span>
|
||||
</div>
|
||||
<button type="button" id="settingsBtn" class="icon-btn" title="Instance Momento" aria-label="Instance Momento">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="settingsPanel" class="settings-panel" hidden>
|
||||
<label class="field">
|
||||
<span>URL de votre instance Momento</span>
|
||||
<input id="baseUrl" type="text" spellcheck="false" placeholder="http://localhost:3000" />
|
||||
</label>
|
||||
<div class="preset-row">
|
||||
<button type="button" class="preset-btn" data-url="https://memento-note.com">Production</button>
|
||||
<button type="button" class="preset-btn" data-url="http://localhost:3000">localhost:3000</button>
|
||||
<button type="button" class="preset-btn" data-url="http://127.0.0.1:3000">127.0.0.1:3000</button>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button type="button" id="applyInstanceBtn" class="btn btn-primary btn-sm">Appliquer & reconnecter</button>
|
||||
<button type="button" id="openLoginBtn" class="btn btn-secondary btn-sm">Ouvrir Momento ↗</button>
|
||||
</div>
|
||||
<p class="settings-hint">
|
||||
Connectez-vous sur <strong>la même URL</strong> dans Chrome (Google OAuth). En dev, utilisez exactement
|
||||
<code>http://localhost:3000</code> ou <code>http://127.0.0.1:3000</code> — pas un mélange des deux.
|
||||
</p>
|
||||
<p id="settingsStatus" class="settings-status" hidden></p>
|
||||
</div>
|
||||
|
||||
<main id="screen" class="main"></main>
|
||||
|
||||
<footer class="footer">
|
||||
<span class="footer-meta">Momento Web Clipper v0.3.0</span>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
703
memento-note/extension/sidepanel.js
Normal file
703
memento-note/extension/sidepanel.js
Normal file
@@ -0,0 +1,703 @@
|
||||
/** 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('Autorisez l’accès à votre instance Momento dans Chrome.')
|
||||
}
|
||||
}
|
||||
|
||||
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 || '', 'fr'))
|
||||
}
|
||||
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 || 'Sans nom'))}</option>`
|
||||
})
|
||||
.join('')
|
||||
return `<select id="notebookSelect" class="notebook-select" aria-label="Carnet de destination">
|
||||
${notebooks.length ? opts : '<option value="">Aucun carnet</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`
|
||||
}
|
||||
|
||||
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 ? 'Connecté' : 'Déconnecté'
|
||||
}
|
||||
|
||||
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> Sélection détectée</span>
|
||||
<button type="button" class="clear-btn" id="clearSel">Ignorer</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>
|
||||
</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} Clipper la sélection
|
||||
</button>`
|
||||
: ''
|
||||
}
|
||||
<button type="button" class="btn ${hasSel ? 'btn-secondary' : 'btn-primary'}" id="clipPageBtn" ${pageRestricted ? 'disabled' : ''}>
|
||||
${ICON_CLIP} Clipper cette page
|
||||
</button>
|
||||
<button type="button" class="btn-link link-only" id="clipLinkBtn" ${pageRestricted ? 'disabled' : ''}>
|
||||
${ICON_LINK} Enregistrer le lien seul
|
||||
</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 || 'Page non accessible'
|
||||
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('Connectez-vous à Momento sur la même URL (bouton « Ouvrir Momento »).')
|
||||
}
|
||||
throw new Error('Impossible de charger les carnets.')
|
||||
}
|
||||
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('Carnets chargés.', 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('Connexion en cours…', false)
|
||||
await loadNotebooks(selectedNotebookId)
|
||||
if (connected) {
|
||||
setSettingsStatus(`Connecté à ${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>`
|
||||
: ''
|
||||
|
||||
const authHint =
|
||||
!connected && errorMessage
|
||||
? `<div class="auth-hint">${escapeHtml(errorMessage)}</div>`
|
||||
: ''
|
||||
|
||||
els.screen.innerHTML = `
|
||||
${restrictedBlock}
|
||||
${authHint}
|
||||
|
||||
<div>
|
||||
<span class="label">Carnet de destination</span>
|
||||
${notebookSelectHtml()}
|
||||
</div>
|
||||
|
||||
<div class="page-card">
|
||||
<span class="sub">Page active</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">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>
|
||||
</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">Aperçu avant enregistrement</span>
|
||||
<label class="field">
|
||||
<span>Titre de la note</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">Extrait</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">Enregistrer dans Momento</button>
|
||||
<button type="button" class="btn-link" id="cancelConfirmBtn">Retour</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">Note enregistrée</span>
|
||||
<div class="note-title"${rtlAttrs(successTitle)}>${escapeHtml(successTitle)}</div>
|
||||
<div class="state-detail">Carnet « ${escapeHtml(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>
|
||||
</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">Échec</div>
|
||||
<div class="state-detail">${escapeHtml(errorMessage || 'Une erreur s\'est produite.')}</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>
|
||||
</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' ? 'Enregistrement…' : 'Analyse…')
|
||||
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('Aucune sélection active.')
|
||||
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 || 'Analyse impossible')
|
||||
|
||||
analyzeResult = analysis
|
||||
editableTitle = analysis.title || pageTitle || pageDomain
|
||||
state = 'confirm'
|
||||
render()
|
||||
} catch (e) {
|
||||
errorMessage = e.message || 'Erreur réseau'
|
||||
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 || 'Enregistrement impossible')
|
||||
|
||||
successTitle = title
|
||||
successTags = analyzeResult.tags || []
|
||||
lastNoteId = saved.noteId
|
||||
lastNoteUrl = saved.noteUrl
|
||||
state = 'success'
|
||||
render()
|
||||
} catch (e) {
|
||||
errorMessage = e.message || 'Erreur réseau'
|
||||
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 () => {
|
||||
applyInstanceConfigVisibility()
|
||||
await loadSettings()
|
||||
try {
|
||||
await ensureApiPermission()
|
||||
} catch (e) {
|
||||
errorMessage = e.message
|
||||
connected = false
|
||||
updateConnBadge()
|
||||
}
|
||||
await refreshPageContext()
|
||||
await syncPickMode()
|
||||
render()
|
||||
})
|
||||
Reference in New Issue
Block a user