fix(extension): sync robuste content/sidepanel, diagnostic panel, preview propre v0.4.4
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled

- content.js : écrit la sélection dans chrome.storage.session/local en plus du message runtime
- sidepanel.js : lit le storage en fallback, loggue chaque étape, affiche un diagnostic panel
- sidepanel.js : avertissement explicite si le content script n'est pas détecté
- sidepanel.js : sépare le rendu Markdown du résumé et l'affichage texte brut de l'extrait
- sidepanel.css : agrandit la zone de prévisualisation
- i18n : nouvelles clés contentScriptMissing, diagnosticsTitle, copyDiagnostics
- locales : correction FR (Retour, Enregistrement, Note, etc.) + version 0.4.4 partout
- extract-article.ts : nettoie textContent des métadonnées (dates, temps de lecture, sources)
- analyze/route.ts : excerpt propre via makeExcerpt
- builds Chrome Store et Firefox regénérés en 0.4.4
This commit is contained in:
Antigravity
2026-07-18 16:56:51 +00:00
parent bff060ad20
commit 0ee2afbd62
67 changed files with 1794 additions and 640 deletions

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { extractArticleFromHtml } from '@/lib/clip/extract-article'
import { extractArticleFromHtml, makeExcerpt } from '@/lib/clip/extract-article'
import { analyzeClipContent } from '@/lib/clip/analyze-clip'
import { resolveClipLocale, wrapClipPlainParagraph } from '@/lib/clip/rtl-content'
@@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
tags: analysis.tags,
readingTime: analysis.readingTimeMinutes,
content: contentHtml,
excerpt: textContent.slice(0, 500),
excerpt: makeExcerpt(textContent, 500),
})
} catch (error) {
console.error('[POST /api/clip/analyze]', error)

View File

@@ -36,7 +36,7 @@
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Memento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
},
"footerVersion": {
"message": "Memento Web Clipper <<<الإصدار>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "لا يستطيع Memento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "ملخص"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Memento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen oder öffnen Sie den Seitenbereich."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Zusammenfassung"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Paste the HTTPS (or LAN) URL of your Memento server. Cookies in this browser handle sign-in."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Summary"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Pegue la URL HTTPS (o LAN) de su servidor Memento. Las cookies en este navegador controlan el inicio de sesión."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIÓN>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Resumen"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "URL HTTPS (یا LAN) سرور Memento خود را جایگذاری کنید. کوکی‌های این مرورگر ورود به سیستم را کنترل می‌کنند."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "خلاصه"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,13 +36,13 @@
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Memento. Les cookies de ce navigateur gèrent la connexion."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento ne peut pas accéder à cet onglet. Vérifiez les autorisations du clavier/extension de site ou ouvrez le panneau latéral."
},
"notebookUnnamed": {
"message": "Carnet sans titre"
"message": "Carnet sans nom"
},
"noNotebooks": {
"message": "Pas encore de carnets"
@@ -63,7 +63,7 @@
"message": "Sélection détectée"
},
"ignore": {
"message": "ignorer"
"message": "Ignorer"
},
"selectionHint": {
"message": "Astuce : surlignez du texte sur la page pour clipper une sélection précise en note."
@@ -123,22 +123,22 @@
"message": "Enregistrer dans Memento"
},
"back": {
"message": "Dos"
"message": "Retour"
},
"analyzingSource": {
"message": "Analyse de la source"
},
"statusAnalyzing": {
"message": "Analyser…"
"message": "Analyse en cours…"
},
"statusSaving": {
"message": "Économie…"
"message": "Enregistrement…"
},
"processingDetail": {
"message": "Génération automatique des tags, résumé sémantique et calcul des embeddings."
},
"noteSaved": {
"message": "Remarque enregistrée"
"message": "Note enregistrée"
},
"sentToNotebook": {
"message": "Enregistré dans $NOTEBOOK$",
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Résumé"
},
"contentScriptMissing": {
"message": "Content script non détecté — rechargez la page pour clipper."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copier"
}
}

View File

@@ -36,7 +36,7 @@
"message": "अपने मोमेंटो सर्वर का HTTPS (या LAN) URL चिपकाएँ। इस ब्राउज़र में कुकीज़ साइन-इन को संभालती हैं।"
},
"footerVersion": {
"message": "मोमेंटो वेब क्लिपर <<<संस्करण>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "मोमेंटो इस टैब तक नहीं पहुंच सकता. कीबोर्ड/साइट एक्सटेंशन अनुमतियां जांचें - या साइड पैनल खोलें।"
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "सारांश"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Memento. I cookie in questo browser gestiscono l'accesso."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIONE>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Riepilogo"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Memento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
},
"footerVersion": {
"message": "Memento Web クリッパー <<<バージョン>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "要約"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Memento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
},
"footerVersion": {
"message": "Memento Web Clipper <<<버전>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "요약"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Plak de HTTPS (of LAN) URL van uw Memento-server. Cookies in deze browser zorgen voor het inloggen."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIE>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Samenvatting"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Wklej adres URL HTTPS (lub LAN) swojego serwera Memento. Pliki cookie w tej przeglądarce obsługują logowanie."
},
"footerVersion": {
"message": "Memento Web Clipper <<<WERSJA>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento nie ma dostępu do tej karty. Sprawdź uprawnienia rozszerzenia klawiatury/witryny — lub otwórz Panel boczny."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Streszczenie"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Cole o URL HTTPS (ou LAN) do seu servidor Memento. Os cookies neste navegador controlam o login."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSÃO>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento não pode acessar esta guia. Verifique as permissões de extensão de teclado/site ou abra o painel lateral."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Resumo"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Вставьте URL-адрес HTTPS (или LAN) вашего сервера Memento. Файлы cookie в этом браузере обрабатывают вход в систему."
},
"footerVersion": {
"message": "Memento Web Clipper <<<ВЕРСИЯ>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento не имеет доступа к этой вкладке. Проверьте разрешения для расширения клавиатуры/сайта или откройте боковую панель."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Сводка"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "粘贴 Memento 服务器的 HTTPS或 LANURL。此浏览器中的 Cookie 处理登录。"
},
"footerVersion": {
"message": "Memento Web Clipper <<<版本>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento 无法访问此选项卡。检查键盘/站点扩展权限 - 或打开侧面板。"
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "摘要"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -1,6 +1,12 @@
/**
* Content script Momento — sélection live, surlignage, communication avec le side panel.
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si longlet était déjà ouvert.
*
* Architecture v0.4.4 :
* - La sélection courante est écrite dans chrome.storage.session/local comme source de vérité
* (filet de sécurité si le runtime.sendMessage est perdu).
* - Les messages runtime servent de notification rapide au side panel.
* - SET_PICK_MODE répond toujours avec létat pickMode + la sélection courante.
*/
;(function initMementoClipperContent() {
if (globalThis.__mementoClipperContent) return
@@ -9,10 +15,16 @@
const HIGHLIGHT_ID = 'memento-clipper-highlight-root'
const BANNER_ID = 'memento-clipper-banner-root'
const STYLE_ID = 'memento-clipper-styles'
const SESSION_KEY = 'memento_clipper_session'
let pickMode = false
let debounceTimer = null
function storageArea() {
// Firefox < 128 na pas storage.session en MV3 ; on fallback sur local.
return chrome.storage.session || chrome.storage.local
}
function getSelectionText() {
return window.getSelection()?.toString().trim() || ''
}
@@ -36,10 +48,25 @@
}
}
async function writeSession(extra = {}) {
try {
await storageArea().set({
[SESSION_KEY]: {
...getPageMeta(),
...extra,
updatedAt: Date.now(),
},
})
} catch (err) {
console.debug('[memento] storage session write failed', err)
}
}
function broadcastSelection() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = setTimeout(async () => {
const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() }
writeSession({ source: 'selection' })
try {
chrome.runtime.sendMessage(payload).catch(() => {})
} catch {
@@ -189,7 +216,7 @@
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === 'PING') {
sendResponse({ ok: true })
sendResponse({ ok: true, pickMode, selection: getPageMeta() })
return true
}
if (message?.type === 'GET_CONTEXT') {
@@ -201,11 +228,13 @@
}
if (message?.type === 'SET_PICK_MODE') {
setPickMode(!!message.enabled)
sendResponse({ ok: true, pickMode })
sendResponse({ ok: true, pickMode, selection: getPageMeta() })
return true
}
return false
})
// Amorcer le storage dès l'injection (utile si une sélection existait avant l'ouverture du side panel).
writeSession({ source: 'init' })
broadcastSelection()
})()

View File

@@ -36,7 +36,7 @@
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Memento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
},
"footerVersion": {
"message": "Memento Web Clipper <<<الإصدار>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "لا يستطيع Memento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "ملخص"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Memento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen oder öffnen Sie den Seitenbereich."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Zusammenfassung"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Paste the HTTPS (or LAN) URL of your Memento server. Cookies in this browser handle sign-in."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Summary"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Pegue la URL HTTPS (o LAN) de su servidor Memento. Las cookies en este navegador controlan el inicio de sesión."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIÓN>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Resumen"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "URL HTTPS (یا LAN) سرور Memento خود را جایگذاری کنید. کوکی‌های این مرورگر ورود به سیستم را کنترل می‌کنند."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "خلاصه"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,13 +36,13 @@
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Memento. Les cookies de ce navigateur gèrent la connexion."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento ne peut pas accéder à cet onglet. Vérifiez les autorisations du clavier/extension de site ou ouvrez le panneau latéral."
},
"notebookUnnamed": {
"message": "Carnet sans titre"
"message": "Carnet sans nom"
},
"noNotebooks": {
"message": "Pas encore de carnets"
@@ -63,7 +63,7 @@
"message": "Sélection détectée"
},
"ignore": {
"message": "ignorer"
"message": "Ignorer"
},
"selectionHint": {
"message": "Astuce : surlignez du texte sur la page pour clipper une sélection précise en note."
@@ -123,22 +123,22 @@
"message": "Enregistrer dans Memento"
},
"back": {
"message": "Dos"
"message": "Retour"
},
"analyzingSource": {
"message": "Analyse de la source"
},
"statusAnalyzing": {
"message": "Analyser…"
"message": "Analyse en cours…"
},
"statusSaving": {
"message": "Économie…"
"message": "Enregistrement…"
},
"processingDetail": {
"message": "Génération automatique des tags, résumé sémantique et calcul des embeddings."
},
"noteSaved": {
"message": "Remarque enregistrée"
"message": "Note enregistrée"
},
"sentToNotebook": {
"message": "Enregistré dans $NOTEBOOK$",
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Résumé"
},
"contentScriptMissing": {
"message": "Content script non détecté — rechargez la page pour clipper."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copier"
}
}

View File

@@ -36,7 +36,7 @@
"message": "अपने मोमेंटो सर्वर का HTTPS (या LAN) URL चिपकाएँ। इस ब्राउज़र में कुकीज़ साइन-इन को संभालती हैं।"
},
"footerVersion": {
"message": "मोमेंटो वेब क्लिपर <<<संस्करण>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "मोमेंटो इस टैब तक नहीं पहुंच सकता. कीबोर्ड/साइट एक्सटेंशन अनुमतियां जांचें - या साइड पैनल खोलें।"
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "सारांश"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Memento. I cookie in questo browser gestiscono l'accesso."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIONE>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Riepilogo"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Memento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
},
"footerVersion": {
"message": "Memento Web クリッパー <<<バージョン>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "要約"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Memento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
},
"footerVersion": {
"message": "Memento Web Clipper <<<버전>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "요약"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Plak de HTTPS (of LAN) URL van uw Memento-server. Cookies in deze browser zorgen voor het inloggen."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIE>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Samenvatting"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Wklej adres URL HTTPS (lub LAN) swojego serwera Memento. Pliki cookie w tej przeglądarce obsługują logowanie."
},
"footerVersion": {
"message": "Memento Web Clipper <<<WERSJA>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento nie ma dostępu do tej karty. Sprawdź uprawnienia rozszerzenia klawiatury/witryny — lub otwórz Panel boczny."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Streszczenie"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Cole o URL HTTPS (ou LAN) do seu servidor Memento. Os cookies neste navegador controlam o login."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSÃO>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento não pode acessar esta guia. Verifique as permissões de extensão de teclado/site ou abra o painel lateral."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Resumo"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Вставьте URL-адрес HTTPS (или LAN) вашего сервера Memento. Файлы cookie в этом браузере обрабатывают вход в систему."
},
"footerVersion": {
"message": "Memento Web Clipper <<<ВЕРСИЯ>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento не имеет доступа к этой вкладке. Проверьте разрешения для расширения клавиатуры/сайта или откройте боковую панель."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Сводка"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "粘贴 Memento 服务器的 HTTPS或 LANURL。此浏览器中的 Cookie 处理登录。"
},
"footerVersion": {
"message": "Memento Web Clipper <<<版本>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento 无法访问此选项卡。检查键盘/站点扩展权限 - 或打开侧面板。"
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "摘要"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -1,6 +1,12 @@
/**
* Content script Momento — sélection live, surlignage, communication avec le side panel.
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si longlet était déjà ouvert.
*
* Architecture v0.4.4 :
* - La sélection courante est écrite dans chrome.storage.session/local comme source de vérité
* (filet de sécurité si le runtime.sendMessage est perdu).
* - Les messages runtime servent de notification rapide au side panel.
* - SET_PICK_MODE répond toujours avec létat pickMode + la sélection courante.
*/
;(function initMementoClipperContent() {
if (globalThis.__mementoClipperContent) return
@@ -9,10 +15,16 @@
const HIGHLIGHT_ID = 'memento-clipper-highlight-root'
const BANNER_ID = 'memento-clipper-banner-root'
const STYLE_ID = 'memento-clipper-styles'
const SESSION_KEY = 'memento_clipper_session'
let pickMode = false
let debounceTimer = null
function storageArea() {
// Firefox < 128 na pas storage.session en MV3 ; on fallback sur local.
return chrome.storage.session || chrome.storage.local
}
function getSelectionText() {
return window.getSelection()?.toString().trim() || ''
}
@@ -36,10 +48,25 @@
}
}
async function writeSession(extra = {}) {
try {
await storageArea().set({
[SESSION_KEY]: {
...getPageMeta(),
...extra,
updatedAt: Date.now(),
},
})
} catch (err) {
console.debug('[memento] storage session write failed', err)
}
}
function broadcastSelection() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = setTimeout(async () => {
const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() }
writeSession({ source: 'selection' })
try {
chrome.runtime.sendMessage(payload).catch(() => {})
} catch {
@@ -189,7 +216,7 @@
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === 'PING') {
sendResponse({ ok: true })
sendResponse({ ok: true, pickMode, selection: getPageMeta() })
return true
}
if (message?.type === 'GET_CONTEXT') {
@@ -201,11 +228,13 @@
}
if (message?.type === 'SET_PICK_MODE') {
setPickMode(!!message.enabled)
sendResponse({ ok: true, pickMode })
sendResponse({ ok: true, pickMode, selection: getPageMeta() })
return true
}
return false
})
// Amorcer le storage dès l'injection (utile si une sélection existait avant l'ouverture du side panel).
writeSession({ source: 'init' })
broadcastSelection()
})()

View File

@@ -44,4 +44,13 @@ function applyShellI18n() {
if (hint) hint.textContent = t('settingsHint')
const footer = document.querySelector('.footer-meta')
if (footer) footer.textContent = t('footerVersion')
if (typeof els !== 'undefined' && els.diagToggle) {
const diagTitle = t('diagnosticsTitle') || 'Diagnostic'
els.diagToggle.title = diagTitle
els.diagToggle.setAttribute('aria-label', diagTitle)
}
const diagTitleEl = document.querySelector('.diag-title')
if (diagTitleEl) diagTitleEl.textContent = t('diagnosticsTitle') || 'Diagnostic'
const copyDiagBtn = document.getElementById('copyDiagBtn')
if (copyDiagBtn) copyDiagBtn.textContent = t('copyDiagnostics') || 'Copier'
}

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "__MSG_extName__",
"version": "0.4.3",
"version": "0.4.4",
"description": "__MSG_extDescription__",
"default_locale": "en",
"permissions": [

View File

@@ -519,12 +519,23 @@ input[type="text"]:focus,
overflow-wrap: anywhere;
}
.excerpt-block .preview-block-body {
min-height: 180px;
max-height: 360px;
min-height: 220px;
max-height: 520px;
overflow-y: auto;
background: #fcfbf9;
font-size: 14px;
line-height: 1.75;
padding: 16px 18px;
}
.excerpt-plain {
white-space: normal;
}
.summary-block .preview-block-body {
min-height: 120px;
max-height: 360px;
overflow-y: auto;
font-style: normal;
color: rgba(28, 28, 28, 0.78);
background: rgba(2, 132, 199, 0.04);
@@ -639,6 +650,77 @@ html[dir="rtl"] .sub {
.page-row .title { font-size: 14px; }
}
/* Avertissement content script */
.cs-warning {
padding: 10px 12px;
border-radius: var(--radius-sm);
background: #fff7ed;
border: 1px solid #fdba74;
color: #9a3412;
font-size: 11px;
line-height: 1.5;
margin-bottom: 8px;
}
/* Panneau de diagnostic */
.diag-panel {
border-top: 1px solid var(--border);
background: #1c1c1c;
color: #e5e5e5;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 10px;
line-height: 1.5;
}
.diag-panel[hidden] { display: none !important; }
.diag-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
background: #2a2a2a;
}
.diag-title {
font-weight: 700;
color: #a3a3a3;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.diag-copy {
background: transparent;
border: 1px solid #525252;
color: #d4d4d4;
border-radius: 6px;
padding: 4px 8px;
font-size: 10px;
cursor: pointer;
}
.diag-copy:hover { background: #404040; }
.diag-body {
max-height: 160px;
overflow-y: auto;
padding: 8px 12px;
}
.diag-line {
padding: 2px 0;
border-bottom: 1px solid #333;
white-space: pre-wrap;
word-break: break-word;
}
.diag-line:last-child { border-bottom: none; }
.diag-toggle {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.diag-toggle:hover { color: var(--ink); }
/* Très petits écrans (mobile debug, fenetres étirées) */
@media (max-width: 320px) {
.brand-text { display: none; }

View File

@@ -24,6 +24,9 @@
<span class="conn-dot"></span>
<span id="connLabel"></span>
</div>
<button type="button" id="diagToggle" class="diag-toggle" title="Diagnostic" aria-label="Diagnostic" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</button>
<button type="button" id="settingsBtn" class="icon-btn" title="" aria-label="">
<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>
@@ -53,6 +56,14 @@
<footer class="footer">
<span class="footer-meta"></span>
</footer>
<div id="diagPanel" class="diag-panel" hidden>
<div class="diag-head">
<span class="diag-title">Diagnostic</span>
<button type="button" id="copyDiagBtn" class="diag-copy">Copier</button>
</div>
<div id="diagBody" class="diag-body"></div>
</div>
</div>
<script src="i18n.js"></script>
<script src="sidepanel.js"></script>

View File

@@ -2,6 +2,7 @@
const ALLOW_INSTANCE_CONFIG = false
const DEFAULT_BASE = 'https://memento-note.com'
const STORAGE_KEYS = { baseUrl: 'memento_clipper_base_url', notebookId: 'memento_clipper_notebook_id' }
const SESSION_KEY = 'memento_clipper_session'
let state = 'idle'
let notebooks = []
@@ -25,6 +26,9 @@ let pendingClipType = 'page'
let analyzeResult = null
let editableTitle = ''
let connected = false
let contentScriptReady = false
const diagLogs = []
let diagOpen = false
const els = {
screen: document.getElementById('screen'),
@@ -36,6 +40,10 @@ const els = {
settingsStatus: document.getElementById('settingsStatus'),
applyInstanceBtn: document.getElementById('applyInstanceBtn'),
openLoginBtn: document.getElementById('openLoginBtn'),
diagPanel: document.getElementById('diagPanel'),
diagToggle: document.getElementById('diagToggle'),
diagBody: document.getElementById('diagBody'),
copyDiagBtn: document.getElementById('copyDiagBtn'),
}
const ICON_SELECT =
@@ -71,6 +79,10 @@ function escapeHtml(s) {
.replace(/"/g, '&quot;')
}
function escapePlainText(s) {
return escapeHtml(s).replace(/\n/g, '<br/>')
}
/**
* Mini-renderer Markdown sûr pour le side panel.
* - Échappe d'abord le texte (anti-XSS)
@@ -136,6 +148,38 @@ function rtlAttrs(text) {
return ` class="text-rtl" dir="rtl"${lang}`
}
// ---------------------------------------------------------------------------
// Diagnostic logging
// ---------------------------------------------------------------------------
function logDiag(label, detail = '') {
const line = `[${new Date().toLocaleTimeString()}] ${label}${detail ? `${detail}` : ''}`
diagLogs.push(line)
if (diagLogs.length > 80) diagLogs.shift()
renderDiag()
console.debug('[memento-diag]', label, detail)
}
function renderDiag() {
if (!els.diagBody) return
els.diagBody.innerHTML = diagLogs.map((l) => `<div class="diag-line">${escapeHtml(l)}</div>`).join('')
els.diagBody.scrollTop = els.diagBody.scrollHeight
}
async function copyDiagnostics() {
try {
const text = diagLogs.join('\n')
await navigator.clipboard.writeText(text)
logDiag('diagnostics copied')
} catch (e) {
logDiag('copy failed', String(e))
}
}
// ---------------------------------------------------------------------------
// Notebooks
// ---------------------------------------------------------------------------
function sortNotebooksHierarchy(list) {
const byParent = new Map()
for (const n of list) {
@@ -189,29 +233,60 @@ async function getActiveTab() {
return tab
}
// ---------------------------------------------------------------------------
// Content script synchronization (v0.4.4)
// ---------------------------------------------------------------------------
function storageArea() {
return chrome.storage.session || chrome.storage.local
}
async function ensureContentScript(tabId) {
logDiag('ensureContentScript start', `tab=${tabId}`)
try {
const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' })
if (resp?.ok) return true
} catch {
/* inject */
if (resp?.ok) {
contentScriptReady = true
logDiag('PING ok', `pickMode=${resp.pickMode} sel=${resp.selection?.text?.length || 0}`)
if (resp.selection) applySelectionFromMessage(resp.selection, { fromPing: true })
return true
}
} catch (err) {
logDiag('PING failed', String(err?.message || err))
}
try {
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] })
return true
} catch {
logDiag('content script injected')
// attendre un court instant que le script s'enregistre
await new Promise((r) => setTimeout(r, 120))
const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' })
contentScriptReady = resp?.ok || false
logDiag('PING after inject', `ok=${contentScriptReady}`)
return contentScriptReady
} catch (err) {
contentScriptReady = false
logDiag('content script inject failed', String(err?.message || err))
return false
}
}
async function setPickModeOnTab(enabled) {
if (!activeTabId || pageRestricted) return
if (!activeTabId || pageRestricted) {
logDiag('setPickMode skipped', `tab=${activeTabId} restricted=${pageRestricted}`)
return false
}
const ok = await ensureContentScript(activeTabId)
if (!ok) return
if (!ok) return false
try {
await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled })
} catch {
/* ignore */
const resp = await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled })
contentScriptReady = resp?.ok || false
logDiag('SET_PICK_MODE ok', `enabled=${enabled} pickMode=${resp?.pickMode}`)
if (resp?.selection) applySelectionFromMessage(resp.selection, { fromSetPickMode: true })
return contentScriptReady
} catch (err) {
contentScriptReady = false
logDiag('SET_PICK_MODE failed', String(err?.message || err))
return false
}
}
@@ -219,6 +294,158 @@ async function syncPickMode() {
await setPickModeOnTab(state === 'idle' && !pageRestricted)
}
async function readSelectionFromStorage() {
try {
const data = await storageArea().get(SESSION_KEY)
const session = data?.[SESSION_KEY]
if (!session) return null
logDiag('storage read', `url=${session.url?.slice(0, 60)} sel=${session.text?.length || 0}`)
return session
} catch (err) {
logDiag('storage read failed', String(err?.message || err))
return null
}
}
function applySelectionFromMessage(msg, { fromStorage = false, fromPing = false, fromSetPickMode = false } = {}) {
if (!msg) return
const normalizeUrl = (u) => {
if (!u) return ''
try { return new URL(u).origin + new URL(u).pathname.replace(/\/+$/, '') }
catch { return u }
}
const msgUrl = msg.url || ''
const curUrl = pageUrl || ''
if (!curUrl) {
if (msgUrl) {
pageUrl = msgUrl
pageTitle = msg.title || pageTitle || ''
try {
const u = new URL(msgUrl)
pageDomain = u.hostname
pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`
} catch { /* ignore */ }
} else {
return
}
} else if (normalizeUrl(msgUrl) !== normalizeUrl(curUrl)) {
return
}
selectionText = msg.text || ''
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
if (msg.lang) pageLang = msg.lang
logDiag('selection applied', `len=${selectionText.length} source=${fromStorage ? 'storage' : fromPing ? 'ping' : fromSetPickMode ? 'setPickMode' : 'message'}`)
if (state === 'idle') {
if (!curUrl) render()
else updateSelectionUI()
}
}
async function refreshPageContext() {
logDiag('refreshPageContext start')
const tab = await getActiveTab()
activeTabId = tab?.id ?? null
pageRestricted = tab?.url ? isRestrictedUrl(tab.url) : false
logDiag('active tab', `id=${activeTabId} url=${tab?.url?.slice(0, 80)} restricted=${pageRestricted}`)
if (!tab?.id || pageRestricted) {
pageUrl = tab?.url || ''
pageTitle = tab?.title || t('pageNotAccessible')
selectionText = ''
contentScriptReady = false
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) {
// En dernier recours, lire depuis le storage session/local.
const session = await readSelectionFromStorage()
if (session) applySelectionFromMessage(session, { fromStorage: true })
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 || ''
logDiag('GET_CONTEXT ok', `html=${pageHtml.length} sel=${selectionText.length}`)
} catch (err) {
logDiag('GET_CONTEXT failed', String(err?.message || err))
// Fallback storage
const session = await readSelectionFromStorage()
if (session) applySelectionFromMessage(session, { fromStorage: true })
}
}
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)
logDiag('notebooks loaded', `${notebooks.length}`)
} catch (e) {
notebooks = []
connected = false
updateConnBadge()
errorMessage = e.message
setSettingsStatus(e.message, true)
logDiag('notebooks failed', e.message)
}
}
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 updateConnBadge() {
if (!els.connBadge) return
els.connBadge.hidden = !connected
@@ -256,7 +483,10 @@ function selectionBlockHtml() {
function actionsBlockHtml() {
const hasSel = Boolean(selectionText)
return `<div class="actions" id="actionsSlot">
const contentScriptWarning = !contentScriptReady && !pageRestricted
? `<div class="cs-warning">${escapeHtml(t('contentScriptMissing') || 'Content script non détecté — rechargez la page pour clipper.')}</div>`
: ''
return `${contentScriptWarning}<div class="actions" id="actionsSlot">
${
hasSel
? `<button type="button" class="btn btn-sky" id="clipSelBtn">
@@ -283,7 +513,6 @@ function bindIdleHandlers() {
document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page'))
document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link'))
// Gérer l'erreur de chargement du favicon
document.querySelector('.page-favicon')?.addEventListener('error', function() {
const fallback = this.getAttribute('data-fallback')
if (fallback && this.src !== fallback) {
@@ -319,160 +548,6 @@ function updateSelectionUI() {
bindIdleHandlers()
}
function applySelectionFromMessage(msg) {
if (!msg) return
// Comparaison d'URL tolérante : ignorer trailing slash, hash, query.
// Compare origin + pathname, pas l'URL complète.
const normalizeUrl = (u) => {
if (!u) return ''
try { return new URL(u).origin + new URL(u).pathname.replace(/\/+$/, '') }
catch { return u }
}
const msgUrl = msg.url || ''
const curUrl = pageUrl || ''
// Si pageUrl n'est pas encore set (race avec refreshPageContext),
// on accepte le premier message qui arrive et on amorce les métadonnées
// de la page depuis le message. Sans ce fallback, le bouton ne s'affiche
// jamais si chrome.tabs.query rate ou revient avant refreshPageContext.
if (!curUrl) {
if (msgUrl) {
pageUrl = msgUrl
pageTitle = msg.title || pageTitle || ''
try {
const u = new URL(msgUrl)
pageDomain = u.hostname
pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`
} catch { /* ignore */ }
} else {
return
}
} else if (normalizeUrl(msgUrl) !== normalizeUrl(curUrl)) {
// URL vraiment différente (autre onglet) — ignorer.
return
}
selectionText = msg.text || ''
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
if (msg.lang) pageLang = msg.lang
if (state === 'idle') {
// Si la page card n'existait pas encore (pageUrl vide avant),
// il faut un render() complet pour qu'elle apparaisse.
if (!curUrl) {
render()
} else {
updateSelectionUI()
}
} else {
console.debug('[memento] selection updated while busy, deferring UI')
}
}
async function refreshPageContext() {
const tab = await getActiveTab()
activeTabId = tab?.id ?? null
// Ne marquer restricted QUE si on a vraiment une URL à analyser.
// tab?.url undefined doit être traité comme "inconnu", pas restricted.
pageRestricted = tab?.url ? isRestrictedUrl(tab.url) : false
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>`
@@ -483,9 +558,14 @@ function renderIdle() {
? `<div class="auth-hint">${escapeHtml(errorMessage)}</div>`
: ''
const csWarning = !contentScriptReady && !pageRestricted
? `<div class="cs-warning">${escapeHtml(t('contentScriptMissing') || 'Content script non détecté — rechargez la page pour clipper.')}</div>`
: ''
els.screen.innerHTML = `
${restrictedBlock}
${authHint}
${csWarning}
<div>
<span class="label">${escapeHtml(t('destinationNotebook'))}</span>
@@ -532,10 +612,10 @@ function renderConfirm() {
const reading = formatReadingTime(analyzeResult?.readingTime)
const tagsHtml = tags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
// Le résumé et l'extrait peuvent contenir du Markdown produit par l'IA.
// On rend le Markdown en HTML (limité et sûr) au lieu de l'afficher brut.
// Le résumé peut contenir du Markdown produit par l'IA.
// L'extrait est du texte brut : on l'affiche tel quel, sans interpréter de Markdown.
const summaryHtml = summary ? renderMarkdownSafe(summary) : ''
const excerptHtml = (excerpt && pendingClipType !== 'link') ? renderMarkdownSafe(excerpt) : ''
const excerptHtml = (excerpt && pendingClipType !== 'link') ? escapePlainText(excerpt) : ''
els.screen.innerHTML = `
<div class="confirm-panel">
@@ -552,7 +632,7 @@ function renderConfirm() {
</section>` : ''}
${excerptHtml ? `<section class="preview-block excerpt-block"${rtlAttrs(excerpt)}>
<header class="preview-block-head"><span class="dot dot-excerpt"></span>${escapeHtml(t('excerptLabel'))}</header>
<div class="preview-block-body md md-excerpt">${excerptHtml}</div>
<div class="preview-block-body md md-excerpt excerpt-plain">${excerptHtml}</div>
</section>` : ''}
</div>
<div class="actions">
@@ -670,6 +750,7 @@ async function runAnalyze(type) {
analyzeBody = { url: pageUrl, html: pageHtml, title: pageTitle, mode: 'article' }
}
logDiag('analyze request', `${type} url=${pageUrl?.slice(0, 60)}`)
const analyzeRes = await fetch(`${apiBase()}/api/clip/analyze`, {
method: 'POST',
credentials: 'include',
@@ -682,10 +763,12 @@ async function runAnalyze(type) {
analyzeResult = analysis
editableTitle = analysis.title || pageTitle || pageDomain
state = 'confirm'
logDiag('analyze ok', `title=${analysis.title?.slice(0, 40)}`)
render()
} catch (e) {
errorMessage = e.message || t('errNetwork')
state = 'error'
logDiag('analyze error', errorMessage)
render()
}
}
@@ -717,20 +800,34 @@ async function runSave() {
lastNoteId = saved.noteId
lastNoteUrl = saved.noteUrl
state = 'success'
logDiag('save ok', `noteId=${lastNoteId}`)
render()
} catch (e) {
errorMessage = e.message || t('errNetwork')
state = 'error'
logDiag('save error', errorMessage)
render()
}
}
chrome.runtime.onMessage.addListener((msg) => {
if (msg?.type === 'SELECTION_CHANGED') applySelectionFromMessage(msg)
if (msg?.type === 'SELECTION_CHANGED') {
logDiag('SELECTION_CHANGED received', `len=${msg.text?.length || 0}`)
applySelectionFromMessage(msg)
}
})
// Surveiller le storage session/local en cas de message perdu.
storageArea().onChanged.addListener((changes) => {
if (changes[SESSION_KEY]?.newValue) {
logDiag('storage changed event')
applySelectionFromMessage(changes[SESSION_KEY].newValue, { fromStorage: true })
}
})
chrome.tabs.onActivated.addListener(async () => {
if (state !== 'idle') return
logDiag('tabs.onActivated')
await refreshPageContext()
await syncPickMode()
render()
@@ -740,6 +837,7 @@ chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.status !== 'complete' || state !== 'idle') return
const tab = await getActiveTab()
if (tab?.id === tabId) {
logDiag('tabs.onUpdated complete')
await refreshPageContext()
await syncPickMode()
render()
@@ -751,6 +849,14 @@ els.settingsBtn?.addEventListener('click', () => {
els.settingsPanel.hidden = !els.settingsPanel.hidden
})
els.diagToggle?.addEventListener('click', () => {
diagOpen = !diagOpen
if (els.diagPanel) els.diagPanel.hidden = !diagOpen
if (els.diagToggle) els.diagToggle.setAttribute('aria-expanded', String(diagOpen))
})
els.copyDiagBtn?.addEventListener('click', () => void copyDiagnostics())
document.querySelectorAll('.preset-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const url = btn.getAttribute('data-url')
@@ -766,12 +872,12 @@ els.openLoginBtn?.addEventListener('click', () => {
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
if (state === 'idle') {
logDiag('visibility visible')
await refreshPageContext()
await syncPickMode()
render()
}
} else if (document.visibilityState === 'hidden') {
// Désactiver le pick mode quand le sidepanel est fermé
await setPickModeOnTab(false)
}
})

View File

@@ -36,7 +36,7 @@
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Memento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
},
"footerVersion": {
"message": "Memento Web Clipper <<<الإصدار>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "لا يستطيع Memento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "ملخص"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Memento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen oder öffnen Sie den Seitenbereich."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Zusammenfassung"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Paste the HTTPS (or LAN) URL of your Memento server. Cookies in this browser handle sign-in."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Summary"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Pegue la URL HTTPS (o LAN) de su servidor Memento. Las cookies en este navegador controlan el inicio de sesión."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIÓN>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Resumen"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "URL HTTPS (یا LAN) سرور Memento خود را جایگذاری کنید. کوکی‌های این مرورگر ورود به سیستم را کنترل می‌کنند."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "خلاصه"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,13 +36,13 @@
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Memento. Les cookies de ce navigateur gèrent la connexion."
},
"footerVersion": {
"message": "Memento Web Clipper 0.4.3"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento ne peut pas accéder à cet onglet. Vérifiez les autorisations du clavier/extension de site ou ouvrez le panneau latéral."
},
"notebookUnnamed": {
"message": "Carnet sans titre"
"message": "Carnet sans nom"
},
"noNotebooks": {
"message": "Pas encore de carnets"
@@ -63,7 +63,7 @@
"message": "Sélection détectée"
},
"ignore": {
"message": "ignorer"
"message": "Ignorer"
},
"selectionHint": {
"message": "Astuce : surlignez du texte sur la page pour clipper une sélection précise en note."
@@ -123,22 +123,22 @@
"message": "Enregistrer dans Memento"
},
"back": {
"message": "Dos"
"message": "Retour"
},
"analyzingSource": {
"message": "Analyse de la source"
},
"statusAnalyzing": {
"message": "Analyser…"
"message": "Analyse en cours…"
},
"statusSaving": {
"message": "Économie…"
"message": "Enregistrement…"
},
"processingDetail": {
"message": "Génération automatique des tags, résumé sémantique et calcul des embeddings."
},
"noteSaved": {
"message": "Remarque enregistrée"
"message": "Note enregistrée"
},
"sentToNotebook": {
"message": "Enregistré dans $NOTEBOOK$",
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Résumé"
},
"contentScriptMissing": {
"message": "Content script non détecté — rechargez la page pour clipper."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copier"
}
}

View File

@@ -36,7 +36,7 @@
"message": "अपने मोमेंटो सर्वर का HTTPS (या LAN) URL चिपकाएँ। इस ब्राउज़र में कुकीज़ साइन-इन को संभालती हैं।"
},
"footerVersion": {
"message": "मोमेंटो वेब क्लिपर <<<संस्करण>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "मोमेंटो इस टैब तक नहीं पहुंच सकता. कीबोर्ड/साइट एक्सटेंशन अनुमतियां जांचें - या साइड पैनल खोलें।"
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "सारांश"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Memento. I cookie in questo browser gestiscono l'accesso."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIONE>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Riepilogo"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Memento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
},
"footerVersion": {
"message": "Memento Web クリッパー <<<バージョン>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "要約"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Memento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
},
"footerVersion": {
"message": "Memento Web Clipper <<<버전>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "요약"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Plak de HTTPS (of LAN) URL van uw Memento-server. Cookies in deze browser zorgen voor het inloggen."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIE>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Samenvatting"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Wklej adres URL HTTPS (lub LAN) swojego serwera Memento. Pliki cookie w tej przeglądarce obsługują logowanie."
},
"footerVersion": {
"message": "Memento Web Clipper <<<WERSJA>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento nie ma dostępu do tej karty. Sprawdź uprawnienia rozszerzenia klawiatury/witryny — lub otwórz Panel boczny."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Streszczenie"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Cole o URL HTTPS (ou LAN) do seu servidor Memento. Os cookies neste navegador controlam o login."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSÃO>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento não pode acessar esta guia. Verifique as permissões de extensão de teclado/site ou abra o painel lateral."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Resumo"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "Вставьте URL-адрес HTTPS (или LAN) вашего сервера Memento. Файлы cookie в этом браузере обрабатывают вход в систему."
},
"footerVersion": {
"message": "Memento Web Clipper <<<ВЕРСИЯ>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento не имеет доступа к этой вкладке. Проверьте разрешения для расширения клавиатуры/сайта или откройте боковую панель."
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "Сводка"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -36,7 +36,7 @@
"message": "粘贴 Memento 服务器的 HTTPS或 LANURL。此浏览器中的 Cookie 处理登录。"
},
"footerVersion": {
"message": "Memento Web Clipper <<<版本>>>"
"message": "Memento Web Clipper 0.4.4"
},
"errPermissionDenied": {
"message": "Memento 无法访问此选项卡。检查键盘/站点扩展权限 - 或打开侧面板。"
@@ -192,5 +192,14 @@
},
"summaryLabel": {
"message": "摘要"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -1,6 +1,12 @@
/**
* Content script Momento — sélection live, surlignage, communication avec le side panel.
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si longlet était déjà ouvert.
*
* Architecture v0.4.4 :
* - La sélection courante est écrite dans chrome.storage.session/local comme source de vérité
* (filet de sécurité si le runtime.sendMessage est perdu).
* - Les messages runtime servent de notification rapide au side panel.
* - SET_PICK_MODE répond toujours avec létat pickMode + la sélection courante.
*/
;(function initMementoClipperContent() {
if (globalThis.__mementoClipperContent) return
@@ -9,10 +15,16 @@
const HIGHLIGHT_ID = 'memento-clipper-highlight-root'
const BANNER_ID = 'memento-clipper-banner-root'
const STYLE_ID = 'memento-clipper-styles'
const SESSION_KEY = 'memento_clipper_session'
let pickMode = false
let debounceTimer = null
function storageArea() {
// Firefox < 128 na pas storage.session en MV3 ; on fallback sur local.
return chrome.storage.session || chrome.storage.local
}
function getSelectionText() {
return window.getSelection()?.toString().trim() || ''
}
@@ -36,10 +48,25 @@
}
}
async function writeSession(extra = {}) {
try {
await storageArea().set({
[SESSION_KEY]: {
...getPageMeta(),
...extra,
updatedAt: Date.now(),
},
})
} catch (err) {
console.debug('[memento] storage session write failed', err)
}
}
function broadcastSelection() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = setTimeout(async () => {
const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() }
writeSession({ source: 'selection' })
try {
chrome.runtime.sendMessage(payload).catch(() => {})
} catch {
@@ -189,7 +216,7 @@
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === 'PING') {
sendResponse({ ok: true })
sendResponse({ ok: true, pickMode, selection: getPageMeta() })
return true
}
if (message?.type === 'GET_CONTEXT') {
@@ -201,11 +228,13 @@
}
if (message?.type === 'SET_PICK_MODE') {
setPickMode(!!message.enabled)
sendResponse({ ok: true, pickMode })
sendResponse({ ok: true, pickMode, selection: getPageMeta() })
return true
}
return false
})
// Amorcer le storage dès l'injection (utile si une sélection existait avant l'ouverture du side panel).
writeSession({ source: 'init' })
broadcastSelection()
})()

View File

@@ -44,4 +44,13 @@ function applyShellI18n() {
if (hint) hint.textContent = t('settingsHint')
const footer = document.querySelector('.footer-meta')
if (footer) footer.textContent = t('footerVersion')
if (typeof els !== 'undefined' && els.diagToggle) {
const diagTitle = t('diagnosticsTitle') || 'Diagnostic'
els.diagToggle.title = diagTitle
els.diagToggle.setAttribute('aria-label', diagTitle)
}
const diagTitleEl = document.querySelector('.diag-title')
if (diagTitleEl) diagTitleEl.textContent = t('diagnosticsTitle') || 'Diagnostic'
const copyDiagBtn = document.getElementById('copyDiagBtn')
if (copyDiagBtn) copyDiagBtn.textContent = t('copyDiagnostics') || 'Copier'
}

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "__MSG_extName__",
"version": "0.4.3",
"version": "0.4.4",
"description": "__MSG_extDescription__",
"default_locale": "en",
"permissions": [

View File

@@ -519,12 +519,23 @@ input[type="text"]:focus,
overflow-wrap: anywhere;
}
.excerpt-block .preview-block-body {
min-height: 180px;
max-height: 360px;
min-height: 220px;
max-height: 520px;
overflow-y: auto;
background: #fcfbf9;
font-size: 14px;
line-height: 1.75;
padding: 16px 18px;
}
.excerpt-plain {
white-space: normal;
}
.summary-block .preview-block-body {
min-height: 120px;
max-height: 360px;
overflow-y: auto;
font-style: normal;
color: rgba(28, 28, 28, 0.78);
background: rgba(2, 132, 199, 0.04);
@@ -639,6 +650,77 @@ html[dir="rtl"] .sub {
.page-row .title { font-size: 14px; }
}
/* Avertissement content script */
.cs-warning {
padding: 10px 12px;
border-radius: var(--radius-sm);
background: #fff7ed;
border: 1px solid #fdba74;
color: #9a3412;
font-size: 11px;
line-height: 1.5;
margin-bottom: 8px;
}
/* Panneau de diagnostic */
.diag-panel {
border-top: 1px solid var(--border);
background: #1c1c1c;
color: #e5e5e5;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 10px;
line-height: 1.5;
}
.diag-panel[hidden] { display: none !important; }
.diag-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
background: #2a2a2a;
}
.diag-title {
font-weight: 700;
color: #a3a3a3;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.diag-copy {
background: transparent;
border: 1px solid #525252;
color: #d4d4d4;
border-radius: 6px;
padding: 4px 8px;
font-size: 10px;
cursor: pointer;
}
.diag-copy:hover { background: #404040; }
.diag-body {
max-height: 160px;
overflow-y: auto;
padding: 8px 12px;
}
.diag-line {
padding: 2px 0;
border-bottom: 1px solid #333;
white-space: pre-wrap;
word-break: break-word;
}
.diag-line:last-child { border-bottom: none; }
.diag-toggle {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.diag-toggle:hover { color: var(--ink); }
/* Très petits écrans (mobile debug, fenetres étirées) */
@media (max-width: 320px) {
.brand-text { display: none; }

View File

@@ -24,6 +24,9 @@
<span class="conn-dot"></span>
<span id="connLabel"></span>
</div>
<button type="button" id="diagToggle" class="diag-toggle" title="Diagnostic" aria-label="Diagnostic" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</button>
<button type="button" id="settingsBtn" class="icon-btn" title="" aria-label="">
<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>
@@ -53,6 +56,14 @@
<footer class="footer">
<span class="footer-meta"></span>
</footer>
<div id="diagPanel" class="diag-panel" hidden>
<div class="diag-head">
<span class="diag-title">Diagnostic</span>
<button type="button" id="copyDiagBtn" class="diag-copy">Copier</button>
</div>
<div id="diagBody" class="diag-body"></div>
</div>
</div>
<script src="i18n.js"></script>
<script src="sidepanel.js"></script>

View File

@@ -2,6 +2,7 @@
const ALLOW_INSTANCE_CONFIG = false
const DEFAULT_BASE = 'https://memento-note.com'
const STORAGE_KEYS = { baseUrl: 'memento_clipper_base_url', notebookId: 'memento_clipper_notebook_id' }
const SESSION_KEY = 'memento_clipper_session'
let state = 'idle'
let notebooks = []
@@ -25,6 +26,9 @@ let pendingClipType = 'page'
let analyzeResult = null
let editableTitle = ''
let connected = false
let contentScriptReady = false
const diagLogs = []
let diagOpen = false
const els = {
screen: document.getElementById('screen'),
@@ -36,6 +40,10 @@ const els = {
settingsStatus: document.getElementById('settingsStatus'),
applyInstanceBtn: document.getElementById('applyInstanceBtn'),
openLoginBtn: document.getElementById('openLoginBtn'),
diagPanel: document.getElementById('diagPanel'),
diagToggle: document.getElementById('diagToggle'),
diagBody: document.getElementById('diagBody'),
copyDiagBtn: document.getElementById('copyDiagBtn'),
}
const ICON_SELECT =
@@ -71,6 +79,10 @@ function escapeHtml(s) {
.replace(/"/g, '&quot;')
}
function escapePlainText(s) {
return escapeHtml(s).replace(/\n/g, '<br/>')
}
/**
* Mini-renderer Markdown sûr pour le side panel.
* - Échappe d'abord le texte (anti-XSS)
@@ -136,6 +148,38 @@ function rtlAttrs(text) {
return ` class="text-rtl" dir="rtl"${lang}`
}
// ---------------------------------------------------------------------------
// Diagnostic logging
// ---------------------------------------------------------------------------
function logDiag(label, detail = '') {
const line = `[${new Date().toLocaleTimeString()}] ${label}${detail ? `${detail}` : ''}`
diagLogs.push(line)
if (diagLogs.length > 80) diagLogs.shift()
renderDiag()
console.debug('[memento-diag]', label, detail)
}
function renderDiag() {
if (!els.diagBody) return
els.diagBody.innerHTML = diagLogs.map((l) => `<div class="diag-line">${escapeHtml(l)}</div>`).join('')
els.diagBody.scrollTop = els.diagBody.scrollHeight
}
async function copyDiagnostics() {
try {
const text = diagLogs.join('\n')
await navigator.clipboard.writeText(text)
logDiag('diagnostics copied')
} catch (e) {
logDiag('copy failed', String(e))
}
}
// ---------------------------------------------------------------------------
// Notebooks
// ---------------------------------------------------------------------------
function sortNotebooksHierarchy(list) {
const byParent = new Map()
for (const n of list) {
@@ -189,29 +233,60 @@ async function getActiveTab() {
return tab
}
// ---------------------------------------------------------------------------
// Content script synchronization (v0.4.4)
// ---------------------------------------------------------------------------
function storageArea() {
return chrome.storage.session || chrome.storage.local
}
async function ensureContentScript(tabId) {
logDiag('ensureContentScript start', `tab=${tabId}`)
try {
const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' })
if (resp?.ok) return true
} catch {
/* inject */
if (resp?.ok) {
contentScriptReady = true
logDiag('PING ok', `pickMode=${resp.pickMode} sel=${resp.selection?.text?.length || 0}`)
if (resp.selection) applySelectionFromMessage(resp.selection, { fromPing: true })
return true
}
} catch (err) {
logDiag('PING failed', String(err?.message || err))
}
try {
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] })
return true
} catch {
logDiag('content script injected')
// attendre un court instant que le script s'enregistre
await new Promise((r) => setTimeout(r, 120))
const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' })
contentScriptReady = resp?.ok || false
logDiag('PING after inject', `ok=${contentScriptReady}`)
return contentScriptReady
} catch (err) {
contentScriptReady = false
logDiag('content script inject failed', String(err?.message || err))
return false
}
}
async function setPickModeOnTab(enabled) {
if (!activeTabId || pageRestricted) return
if (!activeTabId || pageRestricted) {
logDiag('setPickMode skipped', `tab=${activeTabId} restricted=${pageRestricted}`)
return false
}
const ok = await ensureContentScript(activeTabId)
if (!ok) return
if (!ok) return false
try {
await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled })
} catch {
/* ignore */
const resp = await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled })
contentScriptReady = resp?.ok || false
logDiag('SET_PICK_MODE ok', `enabled=${enabled} pickMode=${resp?.pickMode}`)
if (resp?.selection) applySelectionFromMessage(resp.selection, { fromSetPickMode: true })
return contentScriptReady
} catch (err) {
contentScriptReady = false
logDiag('SET_PICK_MODE failed', String(err?.message || err))
return false
}
}
@@ -219,6 +294,158 @@ async function syncPickMode() {
await setPickModeOnTab(state === 'idle' && !pageRestricted)
}
async function readSelectionFromStorage() {
try {
const data = await storageArea().get(SESSION_KEY)
const session = data?.[SESSION_KEY]
if (!session) return null
logDiag('storage read', `url=${session.url?.slice(0, 60)} sel=${session.text?.length || 0}`)
return session
} catch (err) {
logDiag('storage read failed', String(err?.message || err))
return null
}
}
function applySelectionFromMessage(msg, { fromStorage = false, fromPing = false, fromSetPickMode = false } = {}) {
if (!msg) return
const normalizeUrl = (u) => {
if (!u) return ''
try { return new URL(u).origin + new URL(u).pathname.replace(/\/+$/, '') }
catch { return u }
}
const msgUrl = msg.url || ''
const curUrl = pageUrl || ''
if (!curUrl) {
if (msgUrl) {
pageUrl = msgUrl
pageTitle = msg.title || pageTitle || ''
try {
const u = new URL(msgUrl)
pageDomain = u.hostname
pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`
} catch { /* ignore */ }
} else {
return
}
} else if (normalizeUrl(msgUrl) !== normalizeUrl(curUrl)) {
return
}
selectionText = msg.text || ''
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
if (msg.lang) pageLang = msg.lang
logDiag('selection applied', `len=${selectionText.length} source=${fromStorage ? 'storage' : fromPing ? 'ping' : fromSetPickMode ? 'setPickMode' : 'message'}`)
if (state === 'idle') {
if (!curUrl) render()
else updateSelectionUI()
}
}
async function refreshPageContext() {
logDiag('refreshPageContext start')
const tab = await getActiveTab()
activeTabId = tab?.id ?? null
pageRestricted = tab?.url ? isRestrictedUrl(tab.url) : false
logDiag('active tab', `id=${activeTabId} url=${tab?.url?.slice(0, 80)} restricted=${pageRestricted}`)
if (!tab?.id || pageRestricted) {
pageUrl = tab?.url || ''
pageTitle = tab?.title || t('pageNotAccessible')
selectionText = ''
contentScriptReady = false
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) {
// En dernier recours, lire depuis le storage session/local.
const session = await readSelectionFromStorage()
if (session) applySelectionFromMessage(session, { fromStorage: true })
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 || ''
logDiag('GET_CONTEXT ok', `html=${pageHtml.length} sel=${selectionText.length}`)
} catch (err) {
logDiag('GET_CONTEXT failed', String(err?.message || err))
// Fallback storage
const session = await readSelectionFromStorage()
if (session) applySelectionFromMessage(session, { fromStorage: true })
}
}
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)
logDiag('notebooks loaded', `${notebooks.length}`)
} catch (e) {
notebooks = []
connected = false
updateConnBadge()
errorMessage = e.message
setSettingsStatus(e.message, true)
logDiag('notebooks failed', e.message)
}
}
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 updateConnBadge() {
if (!els.connBadge) return
els.connBadge.hidden = !connected
@@ -256,7 +483,10 @@ function selectionBlockHtml() {
function actionsBlockHtml() {
const hasSel = Boolean(selectionText)
return `<div class="actions" id="actionsSlot">
const contentScriptWarning = !contentScriptReady && !pageRestricted
? `<div class="cs-warning">${escapeHtml(t('contentScriptMissing') || 'Content script non détecté — rechargez la page pour clipper.')}</div>`
: ''
return `${contentScriptWarning}<div class="actions" id="actionsSlot">
${
hasSel
? `<button type="button" class="btn btn-sky" id="clipSelBtn">
@@ -283,7 +513,6 @@ function bindIdleHandlers() {
document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page'))
document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link'))
// Gérer l'erreur de chargement du favicon
document.querySelector('.page-favicon')?.addEventListener('error', function() {
const fallback = this.getAttribute('data-fallback')
if (fallback && this.src !== fallback) {
@@ -319,160 +548,6 @@ function updateSelectionUI() {
bindIdleHandlers()
}
function applySelectionFromMessage(msg) {
if (!msg) return
// Comparaison d'URL tolérante : ignorer trailing slash, hash, query.
// Compare origin + pathname, pas l'URL complète.
const normalizeUrl = (u) => {
if (!u) return ''
try { return new URL(u).origin + new URL(u).pathname.replace(/\/+$/, '') }
catch { return u }
}
const msgUrl = msg.url || ''
const curUrl = pageUrl || ''
// Si pageUrl n'est pas encore set (race avec refreshPageContext),
// on accepte le premier message qui arrive et on amorce les métadonnées
// de la page depuis le message. Sans ce fallback, le bouton ne s'affiche
// jamais si chrome.tabs.query rate ou revient avant refreshPageContext.
if (!curUrl) {
if (msgUrl) {
pageUrl = msgUrl
pageTitle = msg.title || pageTitle || ''
try {
const u = new URL(msgUrl)
pageDomain = u.hostname
pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`
} catch { /* ignore */ }
} else {
return
}
} else if (normalizeUrl(msgUrl) !== normalizeUrl(curUrl)) {
// URL vraiment différente (autre onglet) — ignorer.
return
}
selectionText = msg.text || ''
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
if (msg.lang) pageLang = msg.lang
if (state === 'idle') {
// Si la page card n'existait pas encore (pageUrl vide avant),
// il faut un render() complet pour qu'elle apparaisse.
if (!curUrl) {
render()
} else {
updateSelectionUI()
}
} else {
console.debug('[memento] selection updated while busy, deferring UI')
}
}
async function refreshPageContext() {
const tab = await getActiveTab()
activeTabId = tab?.id ?? null
// Ne marquer restricted QUE si on a vraiment une URL à analyser.
// tab?.url undefined doit être traité comme "inconnu", pas restricted.
pageRestricted = tab?.url ? isRestrictedUrl(tab.url) : false
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>`
@@ -483,9 +558,14 @@ function renderIdle() {
? `<div class="auth-hint">${escapeHtml(errorMessage)}</div>`
: ''
const csWarning = !contentScriptReady && !pageRestricted
? `<div class="cs-warning">${escapeHtml(t('contentScriptMissing') || 'Content script non détecté — rechargez la page pour clipper.')}</div>`
: ''
els.screen.innerHTML = `
${restrictedBlock}
${authHint}
${csWarning}
<div>
<span class="label">${escapeHtml(t('destinationNotebook'))}</span>
@@ -532,10 +612,10 @@ function renderConfirm() {
const reading = formatReadingTime(analyzeResult?.readingTime)
const tagsHtml = tags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
// Le résumé et l'extrait peuvent contenir du Markdown produit par l'IA.
// On rend le Markdown en HTML (limité et sûr) au lieu de l'afficher brut.
// Le résumé peut contenir du Markdown produit par l'IA.
// L'extrait est du texte brut : on l'affiche tel quel, sans interpréter de Markdown.
const summaryHtml = summary ? renderMarkdownSafe(summary) : ''
const excerptHtml = (excerpt && pendingClipType !== 'link') ? renderMarkdownSafe(excerpt) : ''
const excerptHtml = (excerpt && pendingClipType !== 'link') ? escapePlainText(excerpt) : ''
els.screen.innerHTML = `
<div class="confirm-panel">
@@ -552,7 +632,7 @@ function renderConfirm() {
</section>` : ''}
${excerptHtml ? `<section class="preview-block excerpt-block"${rtlAttrs(excerpt)}>
<header class="preview-block-head"><span class="dot dot-excerpt"></span>${escapeHtml(t('excerptLabel'))}</header>
<div class="preview-block-body md md-excerpt">${excerptHtml}</div>
<div class="preview-block-body md md-excerpt excerpt-plain">${excerptHtml}</div>
</section>` : ''}
</div>
<div class="actions">
@@ -670,6 +750,7 @@ async function runAnalyze(type) {
analyzeBody = { url: pageUrl, html: pageHtml, title: pageTitle, mode: 'article' }
}
logDiag('analyze request', `${type} url=${pageUrl?.slice(0, 60)}`)
const analyzeRes = await fetch(`${apiBase()}/api/clip/analyze`, {
method: 'POST',
credentials: 'include',
@@ -682,10 +763,12 @@ async function runAnalyze(type) {
analyzeResult = analysis
editableTitle = analysis.title || pageTitle || pageDomain
state = 'confirm'
logDiag('analyze ok', `title=${analysis.title?.slice(0, 40)}`)
render()
} catch (e) {
errorMessage = e.message || t('errNetwork')
state = 'error'
logDiag('analyze error', errorMessage)
render()
}
}
@@ -717,20 +800,34 @@ async function runSave() {
lastNoteId = saved.noteId
lastNoteUrl = saved.noteUrl
state = 'success'
logDiag('save ok', `noteId=${lastNoteId}`)
render()
} catch (e) {
errorMessage = e.message || t('errNetwork')
state = 'error'
logDiag('save error', errorMessage)
render()
}
}
chrome.runtime.onMessage.addListener((msg) => {
if (msg?.type === 'SELECTION_CHANGED') applySelectionFromMessage(msg)
if (msg?.type === 'SELECTION_CHANGED') {
logDiag('SELECTION_CHANGED received', `len=${msg.text?.length || 0}`)
applySelectionFromMessage(msg)
}
})
// Surveiller le storage session/local en cas de message perdu.
storageArea().onChanged.addListener((changes) => {
if (changes[SESSION_KEY]?.newValue) {
logDiag('storage changed event')
applySelectionFromMessage(changes[SESSION_KEY].newValue, { fromStorage: true })
}
})
chrome.tabs.onActivated.addListener(async () => {
if (state !== 'idle') return
logDiag('tabs.onActivated')
await refreshPageContext()
await syncPickMode()
render()
@@ -740,6 +837,7 @@ chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.status !== 'complete' || state !== 'idle') return
const tab = await getActiveTab()
if (tab?.id === tabId) {
logDiag('tabs.onUpdated complete')
await refreshPageContext()
await syncPickMode()
render()
@@ -751,6 +849,14 @@ els.settingsBtn?.addEventListener('click', () => {
els.settingsPanel.hidden = !els.settingsPanel.hidden
})
els.diagToggle?.addEventListener('click', () => {
diagOpen = !diagOpen
if (els.diagPanel) els.diagPanel.hidden = !diagOpen
if (els.diagToggle) els.diagToggle.setAttribute('aria-expanded', String(diagOpen))
})
els.copyDiagBtn?.addEventListener('click', () => void copyDiagnostics())
document.querySelectorAll('.preset-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const url = btn.getAttribute('data-url')
@@ -766,12 +872,12 @@ els.openLoginBtn?.addEventListener('click', () => {
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
if (state === 'idle') {
logDiag('visibility visible')
await refreshPageContext()
await syncPickMode()
render()
}
} else if (document.visibilityState === 'hidden') {
// Désactiver le pick mode quand le sidepanel est fermé
await setPickModeOnTab(false)
}
})

View File

@@ -44,4 +44,13 @@ function applyShellI18n() {
if (hint) hint.textContent = t('settingsHint')
const footer = document.querySelector('.footer-meta')
if (footer) footer.textContent = t('footerVersion')
if (typeof els !== 'undefined' && els.diagToggle) {
const diagTitle = t('diagnosticsTitle') || 'Diagnostic'
els.diagToggle.title = diagTitle
els.diagToggle.setAttribute('aria-label', diagTitle)
}
const diagTitleEl = document.querySelector('.diag-title')
if (diagTitleEl) diagTitleEl.textContent = t('diagnosticsTitle') || 'Diagnostic'
const copyDiagBtn = document.getElementById('copyDiagBtn')
if (copyDiagBtn) copyDiagBtn.textContent = t('copyDiagnostics') || 'Copier'
}

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "__MSG_extName__",
"version": "0.4.3",
"version": "0.4.4",
"description": "__MSG_extDescription__",
"default_locale": "en",
"permissions": [

View File

@@ -519,12 +519,23 @@ input[type="text"]:focus,
overflow-wrap: anywhere;
}
.excerpt-block .preview-block-body {
min-height: 180px;
max-height: 360px;
min-height: 220px;
max-height: 520px;
overflow-y: auto;
background: #fcfbf9;
font-size: 14px;
line-height: 1.75;
padding: 16px 18px;
}
.excerpt-plain {
white-space: normal;
}
.summary-block .preview-block-body {
min-height: 120px;
max-height: 360px;
overflow-y: auto;
font-style: normal;
color: rgba(28, 28, 28, 0.78);
background: rgba(2, 132, 199, 0.04);
@@ -639,6 +650,77 @@ html[dir="rtl"] .sub {
.page-row .title { font-size: 14px; }
}
/* Avertissement content script */
.cs-warning {
padding: 10px 12px;
border-radius: var(--radius-sm);
background: #fff7ed;
border: 1px solid #fdba74;
color: #9a3412;
font-size: 11px;
line-height: 1.5;
margin-bottom: 8px;
}
/* Panneau de diagnostic */
.diag-panel {
border-top: 1px solid var(--border);
background: #1c1c1c;
color: #e5e5e5;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 10px;
line-height: 1.5;
}
.diag-panel[hidden] { display: none !important; }
.diag-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
background: #2a2a2a;
}
.diag-title {
font-weight: 700;
color: #a3a3a3;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.diag-copy {
background: transparent;
border: 1px solid #525252;
color: #d4d4d4;
border-radius: 6px;
padding: 4px 8px;
font-size: 10px;
cursor: pointer;
}
.diag-copy:hover { background: #404040; }
.diag-body {
max-height: 160px;
overflow-y: auto;
padding: 8px 12px;
}
.diag-line {
padding: 2px 0;
border-bottom: 1px solid #333;
white-space: pre-wrap;
word-break: break-word;
}
.diag-line:last-child { border-bottom: none; }
.diag-toggle {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.diag-toggle:hover { color: var(--ink); }
/* Très petits écrans (mobile debug, fenetres étirées) */
@media (max-width: 320px) {
.brand-text { display: none; }

View File

@@ -24,6 +24,9 @@
<span class="conn-dot"></span>
<span id="connLabel"></span>
</div>
<button type="button" id="diagToggle" class="diag-toggle" title="Diagnostic" aria-label="Diagnostic" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</button>
<button type="button" id="settingsBtn" class="icon-btn" title="" aria-label="">
<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>
@@ -53,6 +56,14 @@
<footer class="footer">
<span class="footer-meta"></span>
</footer>
<div id="diagPanel" class="diag-panel" hidden>
<div class="diag-head">
<span class="diag-title">Diagnostic</span>
<button type="button" id="copyDiagBtn" class="diag-copy">Copier</button>
</div>
<div id="diagBody" class="diag-body"></div>
</div>
</div>
<script src="i18n.js"></script>
<script src="sidepanel.js"></script>

View File

@@ -2,6 +2,7 @@
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' }
const SESSION_KEY = 'memento_clipper_session'
let state = 'idle'
let notebooks = []
@@ -25,6 +26,9 @@ let pendingClipType = 'page'
let analyzeResult = null
let editableTitle = ''
let connected = false
let contentScriptReady = false
const diagLogs = []
let diagOpen = false
const els = {
screen: document.getElementById('screen'),
@@ -36,6 +40,10 @@ const els = {
settingsStatus: document.getElementById('settingsStatus'),
applyInstanceBtn: document.getElementById('applyInstanceBtn'),
openLoginBtn: document.getElementById('openLoginBtn'),
diagPanel: document.getElementById('diagPanel'),
diagToggle: document.getElementById('diagToggle'),
diagBody: document.getElementById('diagBody'),
copyDiagBtn: document.getElementById('copyDiagBtn'),
}
const ICON_SELECT =
@@ -71,6 +79,10 @@ function escapeHtml(s) {
.replace(/"/g, '&quot;')
}
function escapePlainText(s) {
return escapeHtml(s).replace(/\n/g, '<br/>')
}
/**
* Mini-renderer Markdown sûr pour le side panel.
* - Échappe d'abord le texte (anti-XSS)
@@ -136,6 +148,38 @@ function rtlAttrs(text) {
return ` class="text-rtl" dir="rtl"${lang}`
}
// ---------------------------------------------------------------------------
// Diagnostic logging
// ---------------------------------------------------------------------------
function logDiag(label, detail = '') {
const line = `[${new Date().toLocaleTimeString()}] ${label}${detail ? `${detail}` : ''}`
diagLogs.push(line)
if (diagLogs.length > 80) diagLogs.shift()
renderDiag()
console.debug('[memento-diag]', label, detail)
}
function renderDiag() {
if (!els.diagBody) return
els.diagBody.innerHTML = diagLogs.map((l) => `<div class="diag-line">${escapeHtml(l)}</div>`).join('')
els.diagBody.scrollTop = els.diagBody.scrollHeight
}
async function copyDiagnostics() {
try {
const text = diagLogs.join('\n')
await navigator.clipboard.writeText(text)
logDiag('diagnostics copied')
} catch (e) {
logDiag('copy failed', String(e))
}
}
// ---------------------------------------------------------------------------
// Notebooks
// ---------------------------------------------------------------------------
function sortNotebooksHierarchy(list) {
const byParent = new Map()
for (const n of list) {
@@ -189,29 +233,60 @@ async function getActiveTab() {
return tab
}
// ---------------------------------------------------------------------------
// Content script synchronization (v0.4.4)
// ---------------------------------------------------------------------------
function storageArea() {
return chrome.storage.session || chrome.storage.local
}
async function ensureContentScript(tabId) {
logDiag('ensureContentScript start', `tab=${tabId}`)
try {
const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' })
if (resp?.ok) return true
} catch {
/* inject */
if (resp?.ok) {
contentScriptReady = true
logDiag('PING ok', `pickMode=${resp.pickMode} sel=${resp.selection?.text?.length || 0}`)
if (resp.selection) applySelectionFromMessage(resp.selection, { fromPing: true })
return true
}
} catch (err) {
logDiag('PING failed', String(err?.message || err))
}
try {
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] })
return true
} catch {
logDiag('content script injected')
// attendre un court instant que le script s'enregistre
await new Promise((r) => setTimeout(r, 120))
const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' })
contentScriptReady = resp?.ok || false
logDiag('PING after inject', `ok=${contentScriptReady}`)
return contentScriptReady
} catch (err) {
contentScriptReady = false
logDiag('content script inject failed', String(err?.message || err))
return false
}
}
async function setPickModeOnTab(enabled) {
if (!activeTabId || pageRestricted) return
if (!activeTabId || pageRestricted) {
logDiag('setPickMode skipped', `tab=${activeTabId} restricted=${pageRestricted}`)
return false
}
const ok = await ensureContentScript(activeTabId)
if (!ok) return
if (!ok) return false
try {
await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled })
} catch {
/* ignore */
const resp = await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled })
contentScriptReady = resp?.ok || false
logDiag('SET_PICK_MODE ok', `enabled=${enabled} pickMode=${resp?.pickMode}`)
if (resp?.selection) applySelectionFromMessage(resp.selection, { fromSetPickMode: true })
return contentScriptReady
} catch (err) {
contentScriptReady = false
logDiag('SET_PICK_MODE failed', String(err?.message || err))
return false
}
}
@@ -219,6 +294,158 @@ async function syncPickMode() {
await setPickModeOnTab(state === 'idle' && !pageRestricted)
}
async function readSelectionFromStorage() {
try {
const data = await storageArea().get(SESSION_KEY)
const session = data?.[SESSION_KEY]
if (!session) return null
logDiag('storage read', `url=${session.url?.slice(0, 60)} sel=${session.text?.length || 0}`)
return session
} catch (err) {
logDiag('storage read failed', String(err?.message || err))
return null
}
}
function applySelectionFromMessage(msg, { fromStorage = false, fromPing = false, fromSetPickMode = false } = {}) {
if (!msg) return
const normalizeUrl = (u) => {
if (!u) return ''
try { return new URL(u).origin + new URL(u).pathname.replace(/\/+$/, '') }
catch { return u }
}
const msgUrl = msg.url || ''
const curUrl = pageUrl || ''
if (!curUrl) {
if (msgUrl) {
pageUrl = msgUrl
pageTitle = msg.title || pageTitle || ''
try {
const u = new URL(msgUrl)
pageDomain = u.hostname
pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`
} catch { /* ignore */ }
} else {
return
}
} else if (normalizeUrl(msgUrl) !== normalizeUrl(curUrl)) {
return
}
selectionText = msg.text || ''
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
if (msg.lang) pageLang = msg.lang
logDiag('selection applied', `len=${selectionText.length} source=${fromStorage ? 'storage' : fromPing ? 'ping' : fromSetPickMode ? 'setPickMode' : 'message'}`)
if (state === 'idle') {
if (!curUrl) render()
else updateSelectionUI()
}
}
async function refreshPageContext() {
logDiag('refreshPageContext start')
const tab = await getActiveTab()
activeTabId = tab?.id ?? null
pageRestricted = tab?.url ? isRestrictedUrl(tab.url) : false
logDiag('active tab', `id=${activeTabId} url=${tab?.url?.slice(0, 80)} restricted=${pageRestricted}`)
if (!tab?.id || pageRestricted) {
pageUrl = tab?.url || ''
pageTitle = tab?.title || t('pageNotAccessible')
selectionText = ''
contentScriptReady = false
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) {
// En dernier recours, lire depuis le storage session/local.
const session = await readSelectionFromStorage()
if (session) applySelectionFromMessage(session, { fromStorage: true })
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 || ''
logDiag('GET_CONTEXT ok', `html=${pageHtml.length} sel=${selectionText.length}`)
} catch (err) {
logDiag('GET_CONTEXT failed', String(err?.message || err))
// Fallback storage
const session = await readSelectionFromStorage()
if (session) applySelectionFromMessage(session, { fromStorage: true })
}
}
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)
logDiag('notebooks loaded', `${notebooks.length}`)
} catch (e) {
notebooks = []
connected = false
updateConnBadge()
errorMessage = e.message
setSettingsStatus(e.message, true)
logDiag('notebooks failed', e.message)
}
}
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 updateConnBadge() {
if (!els.connBadge) return
els.connBadge.hidden = !connected
@@ -256,7 +483,10 @@ function selectionBlockHtml() {
function actionsBlockHtml() {
const hasSel = Boolean(selectionText)
return `<div class="actions" id="actionsSlot">
const contentScriptWarning = !contentScriptReady && !pageRestricted
? `<div class="cs-warning">${escapeHtml(t('contentScriptMissing') || 'Content script non détecté — rechargez la page pour clipper.')}</div>`
: ''
return `${contentScriptWarning}<div class="actions" id="actionsSlot">
${
hasSel
? `<button type="button" class="btn btn-sky" id="clipSelBtn">
@@ -283,7 +513,6 @@ function bindIdleHandlers() {
document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page'))
document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link'))
// Gérer l'erreur de chargement du favicon
document.querySelector('.page-favicon')?.addEventListener('error', function() {
const fallback = this.getAttribute('data-fallback')
if (fallback && this.src !== fallback) {
@@ -319,160 +548,6 @@ function updateSelectionUI() {
bindIdleHandlers()
}
function applySelectionFromMessage(msg) {
if (!msg) return
// Comparaison d'URL tolérante : ignorer trailing slash, hash, query.
// Compare origin + pathname, pas l'URL complète.
const normalizeUrl = (u) => {
if (!u) return ''
try { return new URL(u).origin + new URL(u).pathname.replace(/\/+$/, '') }
catch { return u }
}
const msgUrl = msg.url || ''
const curUrl = pageUrl || ''
// Si pageUrl n'est pas encore set (race avec refreshPageContext),
// on accepte le premier message qui arrive et on amorce les métadonnées
// de la page depuis le message. Sans ce fallback, le bouton ne s'affiche
// jamais si chrome.tabs.query rate ou revient avant refreshPageContext.
if (!curUrl) {
if (msgUrl) {
pageUrl = msgUrl
pageTitle = msg.title || pageTitle || ''
try {
const u = new URL(msgUrl)
pageDomain = u.hostname
pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`
} catch { /* ignore */ }
} else {
return
}
} else if (normalizeUrl(msgUrl) !== normalizeUrl(curUrl)) {
// URL vraiment différente (autre onglet) — ignorer.
return
}
selectionText = msg.text || ''
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
if (msg.lang) pageLang = msg.lang
if (state === 'idle') {
// Si la page card n'existait pas encore (pageUrl vide avant),
// il faut un render() complet pour qu'elle apparaisse.
if (!curUrl) {
render()
} else {
updateSelectionUI()
}
} else {
console.debug('[memento] selection updated while busy, deferring UI')
}
}
async function refreshPageContext() {
const tab = await getActiveTab()
activeTabId = tab?.id ?? null
// Ne marquer restricted QUE si on a vraiment une URL à analyser.
// tab?.url undefined doit être traité comme "inconnu", pas restricted.
pageRestricted = tab?.url ? isRestrictedUrl(tab.url) : false
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>`
@@ -483,9 +558,14 @@ function renderIdle() {
? `<div class="auth-hint">${escapeHtml(errorMessage)}</div>`
: ''
const csWarning = !contentScriptReady && !pageRestricted
? `<div class="cs-warning">${escapeHtml(t('contentScriptMissing') || 'Content script non détecté — rechargez la page pour clipper.')}</div>`
: ''
els.screen.innerHTML = `
${restrictedBlock}
${authHint}
${csWarning}
<div>
<span class="label">${escapeHtml(t('destinationNotebook'))}</span>
@@ -532,10 +612,10 @@ function renderConfirm() {
const reading = formatReadingTime(analyzeResult?.readingTime)
const tagsHtml = tags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
// Le résumé et l'extrait peuvent contenir du Markdown produit par l'IA.
// On rend le Markdown en HTML (limité et sûr) au lieu de l'afficher brut.
// Le résumé peut contenir du Markdown produit par l'IA.
// L'extrait est du texte brut : on l'affiche tel quel, sans interpréter de Markdown.
const summaryHtml = summary ? renderMarkdownSafe(summary) : ''
const excerptHtml = (excerpt && pendingClipType !== 'link') ? renderMarkdownSafe(excerpt) : ''
const excerptHtml = (excerpt && pendingClipType !== 'link') ? escapePlainText(excerpt) : ''
els.screen.innerHTML = `
<div class="confirm-panel">
@@ -552,7 +632,7 @@ function renderConfirm() {
</section>` : ''}
${excerptHtml ? `<section class="preview-block excerpt-block"${rtlAttrs(excerpt)}>
<header class="preview-block-head"><span class="dot dot-excerpt"></span>${escapeHtml(t('excerptLabel'))}</header>
<div class="preview-block-body md md-excerpt">${excerptHtml}</div>
<div class="preview-block-body md md-excerpt excerpt-plain">${excerptHtml}</div>
</section>` : ''}
</div>
<div class="actions">
@@ -670,6 +750,7 @@ async function runAnalyze(type) {
analyzeBody = { url: pageUrl, html: pageHtml, title: pageTitle, mode: 'article' }
}
logDiag('analyze request', `${type} url=${pageUrl?.slice(0, 60)}`)
const analyzeRes = await fetch(`${apiBase()}/api/clip/analyze`, {
method: 'POST',
credentials: 'include',
@@ -682,10 +763,12 @@ async function runAnalyze(type) {
analyzeResult = analysis
editableTitle = analysis.title || pageTitle || pageDomain
state = 'confirm'
logDiag('analyze ok', `title=${analysis.title?.slice(0, 40)}`)
render()
} catch (e) {
errorMessage = e.message || t('errNetwork')
state = 'error'
logDiag('analyze error', errorMessage)
render()
}
}
@@ -717,20 +800,34 @@ async function runSave() {
lastNoteId = saved.noteId
lastNoteUrl = saved.noteUrl
state = 'success'
logDiag('save ok', `noteId=${lastNoteId}`)
render()
} catch (e) {
errorMessage = e.message || t('errNetwork')
state = 'error'
logDiag('save error', errorMessage)
render()
}
}
chrome.runtime.onMessage.addListener((msg) => {
if (msg?.type === 'SELECTION_CHANGED') applySelectionFromMessage(msg)
if (msg?.type === 'SELECTION_CHANGED') {
logDiag('SELECTION_CHANGED received', `len=${msg.text?.length || 0}`)
applySelectionFromMessage(msg)
}
})
// Surveiller le storage session/local en cas de message perdu.
storageArea().onChanged.addListener((changes) => {
if (changes[SESSION_KEY]?.newValue) {
logDiag('storage changed event')
applySelectionFromMessage(changes[SESSION_KEY].newValue, { fromStorage: true })
}
})
chrome.tabs.onActivated.addListener(async () => {
if (state !== 'idle') return
logDiag('tabs.onActivated')
await refreshPageContext()
await syncPickMode()
render()
@@ -740,6 +837,7 @@ chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.status !== 'complete' || state !== 'idle') return
const tab = await getActiveTab()
if (tab?.id === tabId) {
logDiag('tabs.onUpdated complete')
await refreshPageContext()
await syncPickMode()
render()
@@ -751,6 +849,14 @@ els.settingsBtn?.addEventListener('click', () => {
els.settingsPanel.hidden = !els.settingsPanel.hidden
})
els.diagToggle?.addEventListener('click', () => {
diagOpen = !diagOpen
if (els.diagPanel) els.diagPanel.hidden = !diagOpen
if (els.diagToggle) els.diagToggle.setAttribute('aria-expanded', String(diagOpen))
})
els.copyDiagBtn?.addEventListener('click', () => void copyDiagnostics())
document.querySelectorAll('.preset-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const url = btn.getAttribute('data-url')
@@ -766,12 +872,12 @@ els.openLoginBtn?.addEventListener('click', () => {
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
if (state === 'idle') {
logDiag('visibility visible')
await refreshPageContext()
await syncPickMode()
render()
}
} else if (document.visibilityState === 'hidden') {
// Désactiver le pick mode quand le sidepanel est fermé
await setPickModeOnTab(false)
}
})

View File

@@ -17,6 +17,42 @@ export interface ExtractedArticle {
locale: ClipLocaleHint
}
const META_PATTERNS = [
// Dates/heures avec "mis à jour / updated / actualizado / aktualisiert"
/\b(mis à jour|updated at|actualizado|aktualisiert am|aggiornato alle)\b[\s:|-]*.*/gi,
// Temps de lecture
/\b(temps de lecture|reading time|lesedauer|tiempo de lectura|tempo di lettura|czas czytania|время чтения|阅读时间|読了時間)\b[\s:|-]*.*/gi,
// Lignes de type "Source - Aujourd'hui à 09:15 | mis à jour à 11:07"
/^(.*?\s[-|]\s)(aujourd[']?hui|today|hoy|heute|oggi|vandaag|dzisiaj|сегодня|今日)\b.*$/gim,
// Heures seules type "09:15" ou "09:15 | mis à jour..."
/^\s*\d{1,2}:\d{2}(\s*[-|]\s*.*)?$/gm,
]
export function cleanClipText(text: string): string {
let cleaned = text.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n')
for (const pattern of META_PATTERNS) {
cleaned = cleaned.replace(pattern, '')
}
return cleaned
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function makeExcerpt(text: string, maxLen = 500): string {
if (text.length <= maxLen) return text
// Préférer couper à la fin d'une phrase.
const sentenceEnd = text.lastIndexOf('.', maxLen)
if (sentenceEnd > maxLen * 0.6) return text.slice(0, sentenceEnd + 1).trim()
// Sinon couper au dernier espace.
const space = text.lastIndexOf(' ', maxLen)
if (space > maxLen * 0.6) return text.slice(0, space).trim() + '…'
return text.slice(0, maxLen).trim() + '…'
}
export function extractArticleFromHtml(html: string, pageUrl: string): ExtractedArticle | null {
const dom = new JSDOM(html, { url: pageUrl })
const reader = new Readability(dom.window.document)
@@ -48,11 +84,13 @@ export function extractArticleFromHtml(html: string, pageUrl: string): Extracted
const rtlBlocks = applyRtlToHtmlBlocks(sanitized, mergedLocale)
const content = wrapClipArticleHtml(rtlBlocks, mergedLocale)
const cleanText = cleanClipText(article.textContent || '')
return {
title: (article.title || '').trim(),
content,
textContent: (article.textContent || '').trim(),
excerpt: (article.excerpt || '').trim(),
textContent: cleanText,
excerpt: makeExcerpt(cleanText, 500),
locale: mergedLocale,
}
}